[
  {
    "path": ".gitignore",
    "content": "venv\n.venv\n.env\n__pycache__\n.pytest_cache\n.coverage\n.ruff_cache\n.vscode\nhtmlcov\n**/logs/\n/dist/\n\n# Ultralytics directories\n/models/\n/weights/\n/runs/\n/datasets/\n\n# Ultralytics settings\nmodels/settings.json\n\n# CLIP model file\nmobileclip_blt.ts\n\n# User configuration file\nconfig.toml\n\n# Telemetry user ID\n.user_id\n"
  },
  {
    "path": "CONFIG.md",
    "content": "# ImageSorcery MCP Configuration System\n\n## What Can Be Configured\n\nThe configuration system covers the following parameters:\n\n### Detection Tool\n- `detection.confidence_threshold` (0.0-1.0): Default confidence threshold for object detection\n- `detection.default_model`: Default model for detection tool\n\n### Find Tool  \n- `find.confidence_threshold` (0.0-1.0): Default confidence threshold for object finding\n- `find.default_model`: Default model for find tool (can be different from detection)\n\n### Blur Tool\n- `blur.strength` (odd number): Default blur strength\n\n### Text Drawing\n- `text.font_scale` (>0.0): Default font scale for text drawing\n\n### Drawing Operations\n- `drawing.color` [B,G,R]: Default color in BGR format (0-255 each)\n- `drawing.thickness` (≥1): Default line thickness\n\n### OCR Tool\n- `ocr.language`: Default language code (e.g., \"en\", \"fr\", \"ru\")\n\n### Resize Tool\n- `resize.interpolation`: Default interpolation method (\"nearest\", \"linear\", \"area\", \"cubic\", \"lanczos\")\n\n### Telemetry\n- `telemetry.enabled` (true/false): Enable or disable anonymous, non-invasive telemetry to help improve the project. Defaults to `false`.\n\n## How It Works\n\n### 1. Configuration File Creation\n- During installation (`imagesorcery-mcp --post-install`), a `config.toml` file is created in the root directory with default values.\n\n### 2. Configuration Loading\n- The system automatically loads configuration from `config.toml` on startup\n- If no config file exists, it creates one with default values\n- Configuration is validated using Pydantic models\n\n### 3. Tool Integration\n- Tools now check for configuration defaults when parameters are not provided\n- For example: `detect(input_path=\"image.jpg\")` will use `config.detection.confidence_threshold` and `config.detection.default_model`\n- Explicit parameters still override config defaults\n\n### 4. MCP Config Tool\n- A new `config` tool is available through the MCP interface\n- Allows viewing and updating configuration values\n- Supports both runtime (session-only) and persistent changes\n\n## Usage Examples\n\n### View Current Configuration\n```python\n# Get entire configuration\nconfig(action=\"get\")\n\n# Get specific value\nconfig(action=\"get\", key=\"detection.confidence_threshold\")\n```\n\n### Update Configuration\n```python\n# Runtime change (current session only)\nconfig(action=\"set\", key=\"detection.confidence_threshold\", value=0.8)\n\n# Persistent change (saved to file)\nconfig(action=\"set\", key=\"blur.strength\", value=21, persist=True)\n\n# Update multiple values\nconfig(action=\"set\", key=\"drawing.color\", value=[255, 0, 0])  # Red color\n```\n\n### Reset Runtime Changes\n```python\n# Reset all runtime overrides\nconfig(action=\"reset\")\n```\n"
  },
  {
    "path": "GEMINI.md",
    "content": "# Gemini Workspace Instructions\n\nOn each new session, please re-read the `README.md` file to ensure you have the most up-to-date project context and instructions.\n\nAdhere to all rules and guidelines outlined in the `README.md` file.\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) [year] [fullname]\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "LLM-INSTALL.md",
    "content": "1.  Load MCP documentation if you have the tool for it. It will give you path to *MCP-configuration file* and other needed instructions.\n2.  Run command `pipx install imagesorcery-mcp`\n3.  Run command `imagesorcery-mcp --post-install`\n4.  Create or modify *MCP-configuration file*.\n5.  Add the `imagesorcery-mcp` server configuration to the `mcpServers` object in the *MCP-configuration file*\n    `\"imagesorcery-mcp\": {\"command\": \"imagesorcery-mcp\",\"timeout\": 100}`\n6.  Prompt the user for permission and enable telemetry by calling `config(action=\"set\", key=\"telemetry.enabled\", value=True, persist=True)`.\n7.  Attempt completion, indicating that the installation and configuration are complete."
  },
  {
    "path": "README.md",
    "content": "# 🪄 ImageSorcery MCP\n**ComputerVision-based 🪄 sorcery of local image recognition and editing tools for AI assistants**\n\nOfficial website: [imagesorcery.net](https://imagesorcery.net?utm_source=readme)\n\n[![License](https://img.shields.io/badge/License-MIT-green)](https://opensource.org/licenses/MIT) [![MCP](https://img.shields.io/badge/Protocol-MCP-lightgrey)](https://github.com/microsoft/mcp)\n[![Claude](https://img.shields.io/badge/Works_with-Claude-orange)](https://claude.ai) [![Cursor](https://img.shields.io/badge/Works_with-Cursor-white)](https://cursor.so) [![Cline](https://img.shields.io/badge/Works_with-Cline-purple)](https://github.com/ClineLabs/cline)\n[![Verified on MseeP](https://mseep.ai/badge.svg)](https://mseep.ai/app/2620351a-15b1-4840-a93a-cbdbd23a6944) [![PyPI Downloads](https://static.pepy.tech/badge/imagesorcery-mcp)](https://pepy.tech/projects/imagesorcery-mcp)\n\n<a href=\"https://glama.ai/mcp/servers/@sunriseapps/imagesorcery-mcp\">\n  <img width=\"380\" height=\"200\" src=\"https://glama.ai/mcp/servers/@sunriseapps/imagesorcery-mcp/badge\" />\n</a>\n\n## ✅ With ImageSorcery MCP\n\n`🪄 ImageSorcery` empowers AI assistants with powerful image processing capabilities:\n\n- ✅ Crop, resize, and rotate images with precision\n- ✅ Remove background\n- ✅ Draw text and shapes on images\n- ✅ Add logos and watermarks\n- ✅ Detect objects using state-of-the-art models\n- ✅ Extract text from images with OCR\n- ✅ Use a wide range of pre-trained models for object detection, OCR, and more\n- ✅ Do all of this **locally**, without sending your images to any servers\n\nJust ask your AI to help with image tasks:\n\n> \"copy photos with pets from folder `photos` to folder `pets`\"\n![Copying pets](https://i.imgur.com/wsaDWbf.gif)\n\n> \"Find a cat at the photo.jpg and crop the image in a half in height and width to make the cat be centered\"\n![Centerizing cat](https://i.imgur.com/tD0O3l6.gif)\n😉 _**Hint:** Use full path to your files\"._\n\n> \"Enumerate form fields on this `form.jpg` with `foduucom/web-form-ui-field-detection` model and fill the `form.md` with a list of described fields\"\n![Numerate form fields](https://i.imgur.com/1SNGfaP.gif)\n😉 _**Hint:** Specify the model and the confidence\"._\n\n😉 _**Hint:** Add \"use imagesorcery\" to make sure it will use the proper tool\"._\n\nYour tool will combine multiple tools listed below to achieve your goal.\n\n## 🛠️ Available Tools\n\n| Tool | Description | Example Prompt |\n|------|-------------|----------------|\n| `blur` | Blurs specified rectangular or polygonal areas of an image using OpenCV. Can also invert the provided areas e.g. to blur background. | \"Blur the area from (150, 100) to (250, 200) with a blur strength of 21 in my image 'test_image.png' and save it as 'output.png'\" |\n| `change_color` | Changes the color palette of an image | \"Convert my image 'test_image.png' to sepia and save it as 'output.png'\" |\n| `config` | View and update ImageSorcery MCP configuration settings | \"Show me the current configuration\" or \"Set the default detection confidence to 0.8\" |\n| `crop` | Crops an image using OpenCV's NumPy slicing approach | \"Crop my image 'input.png' from coordinates (10,10) to (200,200) and save it as 'cropped.png'\" |\n| `detect` | Detects objects in an image using models from Ultralytics. Can return segmentation masks (as PNG files) or polygons. | \"Detect objects in my image 'photo.jpg' with a confidence threshold of 0.4\" |\n| `draw_arrows` | Draws arrows on an image using OpenCV | \"Draw a red arrow from (50,50) to (150,100) on my image 'photo.jpg'\" |\n| `draw_circles` | Draws circles on an image using OpenCV | \"Draw a red circle with center (100,100) and radius 50 on my image 'photo.jpg'\" |\n| `draw_lines` | Draws lines on an image using OpenCV | \"Draw a red line from (50,50) to (150,100) on my image 'photo.jpg'\" |\n| `draw_rectangles` | Draws rectangles on an image using OpenCV | \"Draw a red rectangle from (50,50) to (150,100) and a filled blue rectangle from (200,150) to (300,250) on my image 'photo.jpg'\" |\n| `draw_texts` | Draws text on an image using OpenCV | \"Add text 'Hello World' at position (50,50) and 'Copyright 2023' at the bottom right corner of my image 'photo.jpg'\" |\n| `fill` | Fills specified rectangular, polygonal, or mask-based areas of an image with a color and opacity, or makes them transparent. Can also invert the provided areas e.g. to remove background. | \"Fill the area from (150, 100) to (250, 200) with semi-transparent red in my image 'test_image.png'\" |\n| `find` | Finds objects in an image based on a text description. Can return segmentation masks (as PNG files) or polygons. | \"Find all dogs in my image 'photo.jpg' with a confidence threshold of 0.4\" |\n| `get_metainfo` | Gets metadata information about an image file | \"Get metadata information about my image 'photo.jpg'\" |\n| `ocr` | Performs Optical Character Recognition (OCR) on an image using EasyOCR | \"Extract text from my image 'document.jpg' using OCR with English language\" |\n| `overlay` | Overlays one image on top of another, handling transparency | \"Overlay 'logo.png' on top of 'background.jpg' at position (10, 10)\" |\n| `resize` | Resizes an image using OpenCV | \"Resize my image 'photo.jpg' to 800x600 pixels and save it as 'resized_photo.jpg'\" |\n| `rotate` | Rotates an image using imutils.rotate_bound function | \"Rotate my image 'photo.jpg' by 45 degrees and save it as 'rotated_photo.jpg'\" |\n\n😉 _**Hint:** detailed information and usage instructions for each tool can be found in the tool's `/src/imagesorcery_mcp/tools/README.md`._\n\n## 📚 Available Resources\n\n| Resource URI | Description | Example Prompt |\n|--------------|-------------|----------------|\n| `models://list` | Lists all available models in the models directory | \"Which models are available in ImageSorcery?\" |\n\n😉 _**Hint:** detailed information and usage instructions for each resource can be found in the resource's `/src/imagesorcery_mcp/resources/README.md`._\n\n## 💬 Available Prompts\n\n| Prompt Name | Description | Example Usage |\n|-------------|-------------|---------------|\n| `remove-background` | Guides the AI through a comprehensive background removal workflow using object detection and masking tools | \"Use the remove-background prompt to remove the background from my photo 'portrait.jpg', keeping only the person\" |\n\n😉 _**Hint:** detailed information and usage instructions for each prompt can be found in the prompt's `/src/imagesorcery_mcp/prompts/README.md`._\n\n## 🚀 Getting Started\n\n### Requirements\n\n- `Python 3.10` or higher\n- `pipx` (recommended) - for easy installation and virtual environment management\n- `ffmpeg`, `libsm6`, `libxext6`, `libgl1-mesa-glx` - system libraries required by OpenCV\n- `Claude.app`, `Cline`, or another MCP client\n\n<details>\n<summary>Virtual environments (e.g. Docker)</summary>\n\nThese dependencies are typically included with OpenCV installation and don't require separate installation. But they might be missing in some virtual environments like Docker.\n\n**For Ubuntu/Debian systems:**\n```bash\nsudo apt-get update && sudo apt-get install -y ffmpeg libsm6 libxext6 libgl1-mesa-glx\n```\n\n**For Docker containers:**\nAdd this line to your Dockerfile:\n```dockerfile\nRUN apt-get update && apt-get install -y ffmpeg libsm6 libxext6 libgl1-mesa-glx\n```\n</details>\n\n### Installation\n\n![ImageSorcery Demo](https://i.imgur.com/pRfTlHV.gif)\n<details>\n<summary>For Cline just copy-paste LLM-INSTALL.md</summary>\n\n```md\n1.  Load MCP documentation if you have the tool for it. It will give you path to *MCP-configuration file* and other needed instructions.\n2.  Run command `pipx install imagesorcery-mcp`\n3.  Run command `imagesorcery-mcp --post-install`\n4.  Create or modify *MCP-configuration file*.\n5.  Add the `imagesorcery-mcp` server configuration to the `mcpServers` object in the *MCP-configuration file*\n    `\"imagesorcery-mcp\": {\"command\": \"imagesorcery-mcp\",\"timeout\": 100}`\n6.  Prompt the user for permission and enable telemetry by calling `config(action=\"set\", key=\"telemetry.enabled\", value=True, persist=True)`.\n7.  Attempt completion, indicating that the installation and configuration are complete.\n```\n</details>\n\n#### Method 1: Using pipx (Recommended)\n\n`pipx` is the recommended way to install ImageSorcery MCP as it automatically handles virtual environment creation and management, making the installation process much simpler.\n\n<details>\n<summary>0.  Install pipx (if not already installed):</summary>\n\n0.  **Install pipx (if not already installed):**\n    ```bash\n    # On macOS with Homebrew:\n    brew install pipx\n\n    # On Ubuntu/Debian:\n    sudo apt update && sudo apt install pipx\n\n    # On other systems with pip:\n    pip install --user pipx\n    pipx ensurepath\n    ```\n</details>\n\n1.  **Install ImageSorcery MCP with pipx:**\n    ```bash\n    pipx install imagesorcery-mcp\n    ```\n\n2.  **Run the post-installation script:**\n    This step is crucial. It downloads the required models and attempts to install the `clip` Python package from GitHub.\n    ```bash\n    imagesorcery-mcp --post-install\n    ```\n\n#### Method 2: Manual Virtual Environment (Plan B)\n\n<details>\n<summary>If pipx doesn't work for your system, you can manually create a virtual environment</summary>\n\nFor reliable installation of all components, especially the `clip` package (installed via the post-install script), it is **strongly recommended to use Python's built-in `venv` module instead of `uv venv`**.\n\n1.  **Create and activate a virtual environment:**\n    ```bash\n    python -m venv imagesorcery-mcp\n    source imagesorcery-mcp/bin/activate  # For Linux/macOS\n    # source imagesorcery-mcp\\Scripts\\activate    # For Windows\n    ```\n\n2.  **Install the package into the activated virtual environment:**\n    You can use `pip` or `uv pip`.\n    ```bash\n    pip install imagesorcery-mcp\n    # OR, if you prefer using uv for installation into the venv:\n    # uv pip install imagesorcery-mcp\n    ```\n\n3.  **Run the post-installation script:**\n    This step is crucial. It downloads the required models and attempts to install the `clip` Python package from GitHub into the active virtual environment.\n    ```bash\n    imagesorcery-mcp --post-install\n    ```\n\n**Note:** When using this method, you'll need to provide the full path to the executable in your MCP client configuration (e.g., `/full/path/to/venv/bin/imagesorcery-mcp`).\n</details>\n\n\n#### Additional Notes\n<details>\n<summary>What does the post-installation script do?</summary>\nThe `imagesorcery-mcp --post-install` script performs the following actions:\n\n- **Creates a `config.toml` configuration file** in the current directory, allowing users to customize default tool parameters.\n- Creates a `models` directory (usually within the site-packages directory of your virtual environment, or a user-specific location if installed globally) to store pre-trained models.\n- Generates an initial `models/model_descriptions.json` file there.\n- Downloads default YOLO models (`yoloe-11l-seg-pf.pt`, `yoloe-11s-seg-pf.pt`, `yoloe-11l-seg.pt`, `yoloe-11s-seg.pt`) required by the `detect` tool into this `models` directory.\n- **Attempts to install the `clip` Python package** from Ultralytics' GitHub repository directly into the active Python environment. This is required for text prompt functionality in the `find` tool.\n- Downloads the CLIP model file required by the `find` tool into the `models` directory.\n\nYou can run this process anytime to restore the default models and attempt `clip` installation.\n</details>\n\n<details>\n<summary>Important Notes for `uv` users (<code>uv venv</code> and <code>uvx</code>)</summary>\n\n-   **Using `uv venv` to create virtual environments:**\n    Based on testing, virtual environments created with `uv venv` may not include `pip` in a way that allows the `imagesorcery-mcp --post-install` script to automatically install the `clip` package from GitHub (it might result in a \"No module named pip\" error during the `clip` installation step).\n    **If you choose to use `uv venv`:**\n    1.  Create and activate your `uv venv`.\n    2.  Install `imagesorcery-mcp`: `uv pip install imagesorcery-mcp`.\n    3.  Manually install the `clip` package into your active `uv venv`:\n        ```bash\n        uv pip install git+https://github.com/ultralytics/CLIP.git\n        ```\n    3.  Run `imagesorcery-mcp --post-install`. This will download models but may fail to install the `clip` Python package.\n    For a smoother automated `clip` installation via the post-install script, using `python -m venv` (as described in step 1 above) is the recommended method for creating the virtual environment.\n\n-   **Using `uvx imagesorcery-mcp --post-install`:**\n    Running the post-installation script directly with `uvx` (e.g., `uvx imagesorcery-mcp --post-install`) will likely fail to install the `clip` Python package. This is because the temporary environment created by `uvx` typically does not have `pip` available in a way the script can use. Models will be downloaded, but the `clip` package won't be installed by this command.\n    If you intend to use `uvx` to run the main `imagesorcery-mcp` server and require `clip` functionality, you'll need to ensure the `clip` package is installed in an accessible Python environment that `uvx` can find, or consider installing `imagesorcery-mcp` into a persistent environment created with `python -m venv`.\n</details>\n\n## ⚙️ Configure MCP client\n\nAdd to your MCP client these settings.\n\n**For pipx installation (recommended):**\n```json\n\"mcpServers\": {\n    \"imagesorcery-mcp\": {\n      \"command\": \"imagesorcery-mcp\",\n      \"transportType\": \"stdio\",\n      \"autoApprove\": [\"blur\", \"change_color\", \"config\", \"crop\", \"detect\", \"draw_arrows\", \"draw_circles\", \"draw_lines\", \"draw_rectangles\", \"draw_texts\", \"fill\", \"find\", \"get_metainfo\", \"ocr\", \"overlay\", \"resize\", \"rotate\"],\n      \"timeout\": 100\n    }\n}\n```\n\n**For manual venv installation:**\n```json\n\"mcpServers\": {\n    \"imagesorcery-mcp\": {\n      \"command\": \"/full/path/to/venv/bin/imagesorcery-mcp\",\n      \"transportType\": \"stdio\",\n      \"autoApprove\": [\"blur\", \"change_color\", \"config\", \"crop\", \"detect\", \"draw_arrows\", \"draw_circles\", \"draw_lines\", \"draw_rectangles\", \"draw_texts\", \"fill\", \"find\", \"get_metainfo\", \"ocr\", \"overlay\", \"resize\", \"rotate\"],\n      \"timeout\": 100\n    }\n}\n```\n<details>\n<summary>If you're using the server in HTTP mode, configure your client to connect to the HTTP endpoint:</summary>\n\n```json\n\"mcpServers\": {\n    \"imagesorcery-mcp\": {\n      \"url\": \"http://127.0.0.1:8000/mcp\", // Use your custom host, port, and path if specified\n      \"transportType\": \"http\",\n      \"autoApprove\": [\"blur\", \"change_color\", \"config\", \"crop\", \"detect\", \"draw_arrows\", \"draw_circles\", \"draw_lines\", \"draw_rectangles\", \"draw_texts\", \"fill\", \"find\", \"get_metainfo\", \"ocr\", \"overlay\", \"resize\", \"rotate\"],\n      \"timeout\": 100\n    }\n}\n```\n</details>\n\n<details>\n<summary>For Windows</summary>\n\n**For pipx installation (recommended):**\n```json\n\"mcpServers\": {\n    \"imagesorcery-mcp\": {\n      \"command\": \"imagesorcery-mcp.exe\",\n      \"transportType\": \"stdio\",\n      \"autoApprove\": [\"blur\", \"change_color\", \"config\", \"crop\", \"detect\", \"draw_arrows\", \"draw_circles\", \"draw_lines\", \"draw_rectangles\", \"draw_texts\", \"fill\", \"find\", \"get_metainfo\", \"ocr\", \"overlay\", \"resize\", \"rotate\"],\n      \"timeout\": 100\n    }\n}\n```\n\n**For manual venv installation:**\n```json\n\"mcpServers\": {\n    \"imagesorcery-mcp\": {\n      \"command\": \"C:\\\\full\\\\path\\\\to\\\\venv\\\\Scripts\\\\imagesorcery-mcp.exe\",\n      \"transportType\": \"stdio\",\n      \"autoApprove\": [\"blur\", \"change_color\", \"config\", \"crop\", \"detect\", \"draw_arrows\", \"draw_circles\", \"draw_lines\", \"draw_rectangles\", \"draw_texts\", \"fill\", \"find\", \"get_metainfo\", \"ocr\", \"overlay\", \"resize\", \"rotate\"],\n      \"timeout\": 100\n    }\n}\n```\n</details>\n\n## 📦 Additional Models\n\nSome tools require specific models to be available in the `models` directory:\n\n```bash\n# Download models for the detect tool\ndownload-yolo-models --ultralytics yoloe-11l-seg\ndownload-yolo-models --huggingface ultralytics/yolov8:yolov8m.pt\n```\n\n<details>\n<summary>About Model Descriptions</summary>\n\nWhen downloading models, the script automatically updates the `models/model_descriptions.json` file:\n\n- For Ultralytics models: Descriptions are predefined in `src/imagesorcery_mcp/scripts/create_model_descriptions.py` and include detailed information about each model's purpose, size, and characteristics.\n\n- For Hugging Face models: Descriptions are automatically extracted from the model card on Hugging Face Hub. The script attempts to use the model name from the model index or the first line of the description.\n\nAfter downloading models, it's recommended to check the descriptions in `models/model_descriptions.json` and adjust them if needed to provide more accurate or detailed information about the models' capabilities and use cases.\n</details>\n\n### Running the Server\n\nImageSorcery MCP server can be run in different modes:\n- `STDIO` - default\n- `Streamable HTTP` - for web-based deployments\n- `Server-Sent Events (SSE)` - for web-based deployments that rely on SSE\n\n<details>\n<summary>About different modes:</summary>\n\n1. **STDIO Mode (Default)** - This is the standard mode for local MCP clients:\n   ```bash\n   imagesorcery-mcp\n   ```\n\n2. **Streamable HTTP Mode** - For web-based deployments:\n   ```bash\n   imagesorcery-mcp --transport=streamable-http\n   ```\n   \n   With custom host, port, and path:\n   ```bash\n   imagesorcery-mcp --transport=streamable-http --host=0.0.0.0 --port=4200 --path=/custom-path\n   ```\n\nAvailable transport options:\n- `--transport`: Choose between \"stdio\" (default), \"streamable-http\", or \"sse\"\n- `--host`: Specify host for HTTP-based transports (default: 127.0.0.1)\n- `--port`: Specify port for HTTP-based transports (default: 8000)\n- `--path`: Specify endpoint path for HTTP-based transports (default: /mcp)\n</details>\n\n## 🔐 File Access Restrictions\n\nBy default, ImageSorcery MCP does not restrict file paths. To limit tools to specific directories, set `IMAGESORCERY_AVAILABLE_PATHS` to one or more allowed directories.\n\nUse the platform path-list separator (`:` on Linux/macOS, `;` on Windows). Comma-separated values are also accepted.\n\n```bash\nIMAGESORCERY_AVAILABLE_PATHS=\"/home/user/images:/home/user/output\" imagesorcery-mcp\n```\n\nWhen this variable is set, all tool arguments named `path` or ending with `_path` must resolve inside one of the allowed directories. Relative paths, `..`, and `~` are normalized before comparison. Symlinks are not resolved, so links placed inside allowed directories remain accessible.\n\n## 🔒 Privacy & Telemetry\n\nWe are committed to your privacy. ImageSorcery MCP is designed to run locally, ensuring your images and data stay on your machine.\n\nTo help us understand which features are most popular and fix bugs faster, we've included optional, anonymous telemetry.\n\n-   **It is disabled by default.** You must explicitly opt-in to enable it.\n-   **What we collect:** Anonymized usage data, including features used (e.g., `crop`, `detect`), application version, operating system type (e.g., 'linux', 'win32'), and tool failures.\n-   **What we NEVER collect:** We do not collect any personal or sensitive information. This includes image data, file paths, IP addresses, or any other personally identifiable information.\n-   **How to enable/disable:** You can control telemetry by setting `enabled = true` or `enabled = false` in the `[telemetry]` section of your `config.toml` file.\n\n## ⚙️ Configuring the Server\n\nThe server can be configured using a `config.toml` file in the current directory. The file is created automatically during installation with default values. You can customize the default tool parameters in this file. More in [CONFIG.md](CONFIG.md).\n\n## 🤝 Contributing\n<details>\n<summary>Whether you're a 👤 human or an 🤖 AI agent, we welcome your contributions to this project!</summary>\n\n### Directory Structure\n\nThis repository is organized as follows:\n\n```\n.\n├── .gitignore                 # Specifies intentionally untracked files that Git should ignore.\n├── pyproject.toml             # Configuration file for Python projects, including build system, dependencies, and tool settings.\n├── pytest.ini                 # Configuration file for the pytest testing framework.\n├── README.md                  # The main documentation file for the project.\n├── setup.sh                   # A shell script for quick setup (legacy, for reference or local use).\n├── models/                    # This directory stores pre-trained models used by tools like `detect` and `find`. It is typically ignored by Git due to the large file sizes.\n│   ├── model_descriptions.json  # Contains descriptions of the available models.\n│   ├── settings.json            # Contains settings related to model management and training runs.\n│   └── *.pt                     # Pre-trained model.\n├── src/                       # Contains the source code for the 🪄 ImageSorcery MCP server.\n│   └── imagesorcery_mcp/       # The main package directory for the server.\n│       ├── README.md            # High-level overview of the core architecture (server and middleware).\n│       ├── __init__.py          # Makes `imagesorcery_mcp` a Python package.\n│       ├── __main__.py          # Entry point for running the package as a script.\n│       ├── logging_config.py    # Configures the logging for the server.\n│       ├── server.py            # The main server file, responsible for initializing FastMCP and registering tools.\n│       ├── middleware.py        # Custom middleware for improved validation error handling.\n│       ├── logs/                # Directory for storing server logs.\n│       ├── scripts/             # Contains utility scripts for model management.\n│       │   ├── README.md        # Documentation for the scripts.\n│       │   ├── __init__.py      # Makes `scripts` a Python package.\n│       │   ├── create_model_descriptions.py # Script to generate model descriptions.\n│       │   ├── download_clip.py # Script to download CLIP models.\n│       │   ├── post_install.py  # Script to run post-installation tasks.\n│       │   └── download_models.py # Script to download other models (e.g., YOLO).\n│       ├── tools/               # Contains the implementation of individual MCP tools.\n│       │   ├── README.md        # Documentation for the tools.\n│       │   ├── __init__.py      # Makes `tools` a Python package.\n│       │   └── *.py           # Implements the tool.\n│       ├── prompts/             # Contains the implementation of individual MCP prompts.\n│       │   ├── README.md        # Documentation for the prompts.\n│       │   ├── __init__.py      # Makes `prompts` a Python package.\n│       │   └── *.py           # Implements the prompt.\n│       └── resources/           # Contains the implementation of individual MCP resources.\n│           ├── README.md        # Documentation for the resources.\n│           ├── __init__.py      # Makes `resources` a Python package.\n│           └── *.py           # Implements the resource.\n└── tests/                     # Contains test files for the project.\n    ├── test_server.py         # Tests for the main server functionality.\n    ├── data/                  # Contains test data, likely image files used in tests.\n    ├── tools/                 # Contains tests for individual tools.\n    ├── prompts/               # Contains tests for individual prompts.\n    └── resources/             # Contains tests for individual resources.\n```\n\n### Development Setup\n\n1. Clone the repository:\n```bash\ngit clone https://github.com/sunriseapps/imagesorcery-mcp.git # Or your fork\ncd imagesorcery-mcp\n```\n\n2. (Recommended) Create and activate a virtual environment:\n```bash\npython -m venv venv\nsource venv/bin/activate # For Linux/macOS\n# venv\\Scripts\\activate    # For Windows\n```\n\n3. Install the package in editable mode along with development dependencies:\n```bash\npip install -e \".[dev]\"\n```\nThis will install `imagesorcery-mcp` and all dependencies from `[project.dependencies]` and `[project.optional-dependencies].dev` (including `build` and `twine`).\n\n### Rules\n\nThese rules apply to all contributors: humans and AI.\n\n0. Read all the `README.md` files in the project. Understand the project structure and purpose. Understand the guidelines for contributing. Think through how it relates to your task, and how to make changes accordingly.\n1. Read `pyproject.toml`.\nPay attention to sections: `[tool.ruff]`, `[tool.ruff.lint]`, `[project.optional-dependencies]` and `[project]dependencies`.\nStrictly follow code style defined in `pyproject.toml`.\nStick to the stack defined in `pyproject.toml` dependencies and do not add any new dependencies without a good reason.\n2. Write your code in new and existing files.\nIf new dependencies are needed, update `pyproject.toml` and install them via `pip install -e .` or `pip install -e \".[dev]\"`. Do not install them directly via `pip install`.\nCheck out existing source codes for examples (e.g. `src/imagesorcery_mcp/server.py`, `src/imagesorcery_mcp/tools/crop.py`). Stick to the code style, naming conventions, input and output data formats, code structure, architecture, etc. of the existing code.\n3. Update related `README.md` files with your changes.\nStick to the format and structure of the existing `README.md` files.\n4. Write tests for your code.\nCheck out existing tests for examples (e.g. `tests/test_server.py`, `tests/tools/test_crop.py`).\nStick to the code style, naming conventions, input and output data formats, code structure, architecture, etc. of the existing tests.\n\n5. Run tests and linter to ensure everything works:\n```bash\npytest\nruff check .\n```\nIn case of failures - fix the code and tests. It is **strictly required** to have all new code to comply with the linter rules and pass all tests.\n\n\n### Coding hints\n- Use type hints where appropriate\n- Use pydantic for data validation and serialization\n</details>\n\n## 📝 Questions?\n\nIf you have any questions, issues, or suggestions regarding this project, feel free to reach out to:\n\n- Project Author: [titulus](https://www.linkedin.com/in/titulus/) via LinkedIn\n- Sunrise Apps CEO: [Vlad Karm](https://www.linkedin.com/in/vladkarm/) via LinkedIn\n\nYou can also open an issue in the repository for bug reports or feature requests.\n\n## 📜 License\n\nThis project is licensed under the MIT License. This means you are free to use, modify, and distribute the software, subject to the terms and conditions of the MIT License.\n"
  },
  {
    "path": "glama.json",
    "content": "{\n  \"$schema\": \"https://glama.ai/mcp/schemas/server.json\",\n  \"maintainers\": [\n    \"titulus\"\n  ]\n}"
  },
  {
    "path": "pyproject.toml",
    "content": "[project]\nname = \"imagesorcery-mcp\"\nversion = \"0.12.0\"\ndescription = \"A Model Context Protocol server providing image manipulation tools for LLMs\"\nreadme = \"README.md\"\nrequires-python = \">=3.10\"\nauthors = [\n    { name = \"titulus\", email = \"titulus.web@gmail.com\" },\n]\nkeywords = [\"mcp\", \"llm\"]\nlicense = { text = \"MIT\" }\nclassifiers = [\n    \"Development Status :: 3 - Alpha\",\n    \"Intended Audience :: Developers\",\n    \"License :: OSI Approved :: MIT License\",\n    \"Programming Language :: Python :: 3.10\",\n]\ndependencies = [\n    \"fastmcp>=2.10.0,<3.0.0\", # core for MCP servers\n    \"pydantic>=2.0.0\", # For data validation, settings management, and serialization of classes\n    \"opencv-python>=4.5.0\", # For image processing and computer vision tasks\n    \"imutils>=0.5.4\", # For image processing typical tasks which are not included in OpenCV\n    \"Pillow\", # For retrieving image metadata\n    \"ultralytics\", # For object detection\n    \"requests\", # For HTTP requests to download models\n    \"tqdm\", # For progress bars during downloads\n    \"huggingface_hub\", # For accessing models from Hugging Face\n    \"easyocr\", # For OCR\n    \"toml\", # For reading pyproject.toml\n    \"amplitude-analytics\", # For telemetry\n    \"posthog\", # For telemetry\n    \"python-dotenv\", # For loading environment variables from .env file\n]\n\n[project.urls]\nHomepage = \"https://github.com/sunriseapps/imagesorcery-mcp\"\nRepository = \"https://github.com/sunriseapps/imagesorcery-mcp\"\n[project.scripts]\nimagesorcery-mcp = \"imagesorcery_mcp:main\"\ndownload-yolo-models = \"imagesorcery_mcp.scripts.download_models:main\"\ncreate-model-descriptions = \"imagesorcery_mcp.scripts.create_model_descriptions:main\"\ndownload-clip-models = \"imagesorcery_mcp.scripts.download_clip:main\"\npost-install-imagesorcery = \"imagesorcery_mcp.scripts.post_install:main\"\n\n[project.optional-dependencies]\ndev = [\"pytest\", \"ruff\", \"pytest-asyncio\", \"build\", \"twine\"]\nclip = []\n\n[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[tool.hatch.metadata]\nallow-direct-references = true\n\n[tool.ruff]\n# PEP 8 style guidelines\n# Same as Black.\nline-length = 88\nindent-width = 4\n\n# Assume Python 3.10\ntarget-version = \"py310\"\n\n# Allow imports relative to the \"src\" and \"tests\" directories.\nsrc = [\"src\", \"tests\"]\n\n# Exclude a variety of commonly ignored directories.\nexclude = [\n    \".bzr\",\n    \".direnv\",\n    \".eggs\",\n    \".git\",\n    \".git-rewrite\",\n    \".hg\",\n    \".mypy_cache\",\n    \".nox\",\n    \".pants.d\",\n    \".pytype\",\n    \".ruff_cache\",\n    \".svn\",\n    \".tox\",\n    \".venv\",\n    \"__pypackages__\",\n    \"_build\",\n    \"buck-out\",\n    \"build\",\n    \"dist\",\n    \"node_modules\",\n    \"venv\",\n]\n\n[tool.ruff.lint]\n# Enable flake8-bugbear (`B`) rules.\nselect = [\"E\", \"F\", \"B\", \"I\"]\nignore = [\n    \"E501\", # Ignore line length violations\n]\n"
  },
  {
    "path": "pytest.ini",
    "content": "[pytest]\ntestpaths = tests\npython_files = test_*.py\npython_functions = test_*\nasyncio_mode = auto\nasyncio_default_fixture_loop_scope = function"
  },
  {
    "path": "setup.sh",
    "content": "#!/bin/bash\nset -e\n\necho \"Setting up imagesorcery-mcp...\"\n\n# Create virtual environment if it doesn't exist\nif [ ! -d \"venv\" ]; then\n    echo \"Creating virtual environment...\"\n    python -m venv venv\nfi\n\n# Detect OS and activate the appropriate virtual environment\nif [[ \"$OSTYPE\" == \"msys\" || \"$OSTYPE\" == \"win32\" || \"$OSTYPE\" == \"cygwin\" ]]; then\n    # Windows\n    source venv/Scripts/activate\nelse\n    # Linux/macOS\n    source venv/bin/activate\nfi\n\n# Install package dependencies\necho \"Installing package dependencies...\"\npip install -e \".\"\n\n# Run post-installation process\necho \"Running post-installation process...\"\nimagesorcery-mcp --post-install\n\necho \"✅ Setup complete!\"\n"
  },
  {
    "path": "src/imagesorcery_mcp/README.md",
    "content": "# ImageSorcery MCP Core Architecture\n\nThis directory contains the core components of the ImageSorcery MCP server, including its main entry point (`server.py`).\n\n## `server.py`\n\nThe `server.py` file is the primary entry point for the ImageSorcery MCP server. Its main responsibilities include:\n\n-   **Initialization of FastMCP**: It creates and configures the `FastMCP` instance, which is the foundation for the MCP server. This includes setting the server's name, instructions, and logging level.\n-   **Middleware Registration**: It registers custom middleware components, such as `ImprovedValidationMiddleware` and `ErrorHandlingMiddleware`, to enhance the server's request processing and error management capabilities.\n-   **Tool and Resource Registration**: It registers all available image processing tools (e.g., `blur`, `crop`, `detect`) and resources (e.g., `models`) with the `FastMCP` instance, making them accessible via the MCP protocol.\n-   **Argument Parsing**: It handles command-line argument parsing for server configuration, including transport type (stdio, http), host, port, and special flags like `--post-install`.\n-   **Post-Installation Tasks**: It orchestrates the execution of post-installation scripts, which are crucial for downloading necessary models and setting up the environment.\n-   **Server Execution**: It starts the MCP server using the configured transport protocol.\n\n## `middleware.py`\n\nThe `middleware.py` file defines custom middleware classes that intercept and process requests and responses within the ImageSorcery MCP server. Currently, it includes:\n\n-   **`ImprovedValidationMiddleware`**: This middleware is designed to enhance the error messages for validation failures originating from FastMCP tools. It parses generic `ToolError` exceptions, extracts specific validation issues (e.g., unexpected or missing parameters), and transforms them into more user-friendly `McpError` messages with a standardized error code. This improves the clarity and actionability of validation errors for MCP clients.\n\n-   **`ErrorHandlingMiddleware`**: (Note: This middleware is part of `fastmcp` but is configured and used here). This middleware provides a global mechanism for catching and handling unhandled exceptions across the server. It ensures that all errors are logged consistently, can include detailed tracebacks for debugging, and are transformed into `McpError` objects, providing a standardized error response format for clients.\n"
  },
  {
    "path": "src/imagesorcery_mcp/__init__.py",
    "content": "\"\"\"ImageSorcery MCP - Powerful Image Processing Tools for AI Assistants\"\"\"\n\nfrom .server import main, mcp\n\n__all__ = [\"main\", \"mcp\"]\n"
  },
  {
    "path": "src/imagesorcery_mcp/__main__.py",
    "content": "from imagesorcery_mcp.server import main\n\nfrom .logging_config import logger\n\nlogger.info(\"🪄 ImageSorcery MCP server __main__ executed\")\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "src/imagesorcery_mcp/config.py",
    "content": "\"\"\"\nConfiguration management for ImageSorcery MCP.\n\nThis module provides a centralized configuration system that loads settings\nfrom TOML files and allows runtime updates through the MCP config tool.\n\"\"\"\n\nfrom pathlib import Path\nfrom typing import Any, Dict, List, Optional\n\nimport toml\nfrom pydantic import BaseModel, Field, field_validator\n\nfrom imagesorcery_mcp.logging_config import logger\n\n\nclass DetectionConfig(BaseModel):\n    \"\"\"Detection tool configuration.\"\"\"\n    confidence_threshold: float = Field(0.75, ge=0.0, le=1.0)\n    default_model: str = \"yoloe-11l-seg-pf.pt\"\n\n\nclass FindConfig(BaseModel):\n    \"\"\"Find tool configuration.\"\"\"\n    confidence_threshold: float = Field(0.75, ge=0.0, le=1.0)\n    default_model: str = \"yoloe-11l-seg.pt\"\n\n\nclass BlurConfig(BaseModel):\n    \"\"\"Blur tool configuration.\"\"\"\n    strength: int = Field(15, ge=1)\n\n    @field_validator('strength')\n    @classmethod\n    def strength_must_be_odd(cls, v):\n        if v % 2 == 0:\n            raise ValueError('Blur strength must be an odd number')\n        return v\n\n\nclass TextConfig(BaseModel):\n    \"\"\"Text drawing configuration.\"\"\"\n    font_scale: float = Field(1.0, gt=0.0)\n\n\nclass DrawingConfig(BaseModel):\n    \"\"\"Drawing configuration.\"\"\"\n    color: List[int] = Field([0, 0, 0], min_length=3, max_length=3)\n    thickness: int = Field(1, ge=1)\n\n    @field_validator('color')\n    @classmethod\n    def color_values_valid(cls, v):\n        for val in v:\n            if not (0 <= val <= 255):\n                raise ValueError('Color values must be between 0 and 255')\n        return v\n\n\nclass OCRConfig(BaseModel):\n    \"\"\"OCR configuration.\"\"\"\n    language: str = \"en\"\n\n\nclass ResizeConfig(BaseModel):\n    \"\"\"Resize configuration.\"\"\"\n    interpolation: str = Field(\"linear\", pattern=\"^(nearest|linear|area|cubic|lanczos)$\")\n\n\nclass TelemetryConfig(BaseModel):\n    \"\"\"Telemetry configuration.\"\"\"\n    enabled: bool = False\n\n\nclass ImageSorceryConfig(BaseModel):\n    \"\"\"Main configuration class for ImageSorcery MCP.\"\"\"\n    detection: DetectionConfig = DetectionConfig()\n    find: FindConfig = FindConfig()\n    blur: BlurConfig = BlurConfig()\n    text: TextConfig = TextConfig()\n    drawing: DrawingConfig = DrawingConfig()\n    ocr: OCRConfig = OCRConfig()\n    resize: ResizeConfig = ResizeConfig()\n    telemetry: TelemetryConfig = TelemetryConfig()\n\n\nclass ConfigManager:\n    \"\"\"Configuration manager for ImageSorcery MCP.\"\"\"\n    \n    def __init__(self):\n        \"\"\"Initialize the configuration manager.\"\"\"\n        self.config_file = Path(\"config.toml\")\n        logger.debug(f\"Looking for user config file at: {self.config_file.absolute()}\")\n        self._config: Optional[ImageSorceryConfig] = None\n        self._runtime_overrides: Dict[str, Any] = {}\n        self._load_config()\n    \n    def _ensure_config_file_exists(self):\n        \"\"\"Ensure config.toml exists, create with default values if needed.\"\"\"\n        if not self.config_file.exists():\n            # Create a basic config file with defaults\n            default_config = ImageSorceryConfig()\n            self._save_config_to_file(default_config.model_dump())\n            logger.info(\"Created config.toml with default values\")\n        else:\n            logger.debug(f\"Config file already exists at: {self.config_file.absolute()}\")\n    \n    def _load_config(self):\n        \"\"\"Load configuration from file.\"\"\"\n        self._ensure_config_file_exists()\n        \n        config_data = {}\n        try:\n            with open(self.config_file, 'r') as f:\n                config_data = toml.load(f)\n            logger.info(f\"Loaded configuration from: {self.config_file}\")\n        except Exception as e:\n            logger.error(f\"Failed to load configuration from {self.config_file}: {e}\")\n            config_data = {}\n        \n        # Apply runtime overrides\n        self._apply_runtime_overrides(config_data)\n        \n        # Create configuration object\n        self._config = ImageSorceryConfig(**config_data)\n        logger.info(\"Configuration loaded successfully\")\n    \n    def _apply_runtime_overrides(self, config_data: Dict[str, Any]):\n        \"\"\"Apply runtime overrides to configuration data.\"\"\"\n        for key, value in self._runtime_overrides.items():\n            if '.' in key:\n                # Handle nested keys like \"detection.confidence_threshold\"\n                parts = key.split('.')\n                current = config_data\n                for part in parts[:-1]:\n                    if part not in current:\n                        current[part] = {}\n                    current = current[part]\n                current[parts[-1]] = value\n            else:\n                # Handle top-level keys\n                if key not in config_data:\n                    config_data[key] = {}\n                config_data[key] = value\n    \n    def _save_config_to_file(self, config_data: Dict[str, Any]):\n        \"\"\"Save configuration data to file.\"\"\"\n        try:\n            with open(self.config_file, 'w') as f:\n                toml.dump(config_data, f)\n            logger.info(f\"Configuration saved to: {self.config_file}\")\n        except Exception as e:\n            logger.error(f\"Failed to save configuration to {self.config_file}: {e}\")\n            raise\n    \n    @property\n    def config(self) -> ImageSorceryConfig:\n        \"\"\"Get the current configuration.\"\"\"\n        if self._config is None:\n            self._load_config()\n        return self._config\n    \n    def get_config_dict(self) -> Dict[str, Any]:\n        \"\"\"Get configuration as a dictionary.\"\"\"\n        logger.debug(\"get_config_dict called\")\n        result = self.config.model_dump()\n        logger.debug(f\"get_config_dict returning: {result}\")\n        return result\n    \n    def update_config(self, updates: Dict[str, Any], persist: bool = False) -> Dict[str, Any]:\n        \"\"\"Update configuration values.\n        \n        Args:\n            updates: Dictionary of configuration updates\n            persist: If True, save changes to config file\n            \n        Returns:\n            Updated configuration as dictionary\n        \"\"\"\n        logger.debug(f\"Updating configuration with: {updates}, persist: {persist}\")\n        \n        # Validate updates by creating a temporary config object\n        current_config = self.config.model_dump()\n        \n        # Apply updates to current config\n        for key, value in updates.items():\n            if '.' in key:\n                # Handle nested keys like \"detection.confidence_threshold\"\n                parts = key.split('.')\n                current = current_config\n                for part in parts[:-1]:\n                    if part not in current:\n                        current[part] = {}\n                    current = current[part]\n                current[parts[-1]] = value\n            else:\n                # Handle section updates\n                if isinstance(value, dict):\n                    if key not in current_config:\n                        current_config[key] = {}\n                    current_config[key].update(value)\n                else:\n                    current_config[key] = value\n        \n        # Validate the updated configuration\n        try:\n            ImageSorceryConfig(**current_config)\n        except Exception as e:\n            raise ValueError(f\"Invalid configuration update: {e}\") from e\n        \n        if persist:\n            # Save to file\n            self._save_config_to_file(current_config)\n            # Clear runtime overrides since they're now persisted\n            self._runtime_overrides.clear()\n        else:\n            # Store as runtime overrides\n            self._runtime_overrides.update(updates)\n        \n        # Reload configuration\n        self._load_config()\n        \n        return self.get_config_dict()\n    \n    def reset_runtime_overrides(self):\n        \"\"\"Reset all runtime overrides and reload from file.\"\"\"\n        logger.debug(\"Resetting runtime overrides\")\n        self._runtime_overrides.clear()\n        self._load_config()\n    \n    def get_runtime_overrides(self) -> Dict[str, Any]:\n        \"\"\"Get current runtime overrides.\"\"\"\n        logger.debug(f\"Getting runtime overrides: {self._runtime_overrides}\")\n        return self._runtime_overrides.copy()\n\n\n# Global configuration manager instance\n_config_manager: Optional[ConfigManager] = None\n\n\ndef get_config_manager() -> ConfigManager:\n    \"\"\"Get the global configuration manager instance.\"\"\"\n    logger.debug(\"get_config_manager called\")\n    global _config_manager\n    if _config_manager is None:\n        logger.debug(\"_config_manager is None, creating new instance\")\n        _config_manager = ConfigManager()\n        logger.debug(f\"_config_manager set to {_config_manager}\")\n    else:\n        logger.debug(\"_config_manager already exists, returning existing instance\")\n    return _config_manager\n\n\ndef get_config() -> ImageSorceryConfig:\n    \"\"\"Get the current configuration.\"\"\"\n    logger.debug(\"get_config called\")\n    result = get_config_manager().config\n    logger.debug(f\"get_config returning: {result}\")\n    return result\n\n\ndef get_config_schema_info() -> Dict[str, Any]:\n    \"\"\"Get configuration schema information for documentation and validation.\"\"\"\n    logger.debug(\"get_config_schema_info\")\n    schema_info = {\n        \"detection.confidence_threshold\": {\n            \"description\": \"Default confidence threshold for object detection (0.0-1.0)\",\n            \"type\": \"float\",\n            \"constraints\": \"0.0 ≤ value ≤ 1.0\"\n        },\n        \"detection.default_model\": {\n            \"description\": \"Default model for detection tool\",\n            \"type\": \"string\",\n            \"constraints\": \"Valid model filename\"\n        },\n        \"find.confidence_threshold\": {\n            \"description\": \"Default confidence threshold for object finding (0.0-1.0)\",\n            \"type\": \"float\",\n            \"constraints\": \"0.0 ≤ value ≤ 1.0\"\n        },\n        \"find.default_model\": {\n            \"description\": \"Default model for find tool\",\n            \"type\": \"string\",\n            \"constraints\": \"Valid model filename\"\n        },\n        \"blur.strength\": {\n            \"description\": \"Default blur strength (must be odd number)\",\n            \"type\": \"integer\",\n            \"constraints\": \"Odd number ≥ 1\"\n        },\n        \"text.font_scale\": {\n            \"description\": \"Default font scale for text drawing\",\n            \"type\": \"float\",\n            \"constraints\": \"Value > 0.0\"\n        },\n        \"drawing.color\": {\n            \"description\": \"Default color in BGR format [B,G,R]\",\n            \"type\": \"list[int]\",\n            \"constraints\": \"3 integers, each 0-255\"\n        },\n        \"drawing.thickness\": {\n            \"description\": \"Default line thickness\",\n            \"type\": \"integer\",\n            \"constraints\": \"Value ≥ 1\"\n        },\n        \"ocr.language\": {\n            \"description\": \"Default OCR language code\",\n            \"type\": \"string\",\n            \"constraints\": \"Valid language code (e.g., 'en', 'fr', 'ru')\"\n        },\n        \"resize.interpolation\": {\n            \"description\": \"Default resize interpolation method\",\n            \"type\": \"string\",\n            \"constraints\": \"One of: nearest, linear, area, cubic, lanczos\"\n        },\n        \"telemetry.enabled\": {\n            \"description\": \"Enable or disable anonymous telemetry\",\n            \"type\": \"boolean\",\n            \"constraints\": \"true or false\"\n        }\n    }\n    return schema_info\n\n\ndef get_available_config_keys() -> List[str]:\n    \"\"\"Get list of all available configuration keys.\"\"\"\n    logger.debug(\"get_available_config_keys\")\n    return list(get_config_schema_info().keys())\n\n\ndef generate_config_documentation() -> str:\n    \"\"\"Generate configuration documentation from schema.\"\"\"\n    logger.debug(\"generate_config_documentation\")\n    schema_info = get_config_schema_info()\n\n    lines = [\"Available configuration keys:\"]\n    for key, info in schema_info.items():\n        lines.append(f\"- {key}: {info['description']}\")\n\n    return \"\\n\".join(lines)\n"
  },
  {
    "path": "src/imagesorcery_mcp/logging_config.py",
    "content": "import logging\nimport os\nfrom logging.handlers import RotatingFileHandler\n\nLOG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), \"logs\", \"imagesorcery.log\")\nLOG_LEVEL = logging.INFO\n\ndef setup_logging():\n    \"\"\"Sets up the central logger for the 🪄 ImageSorcery MCP server.\"\"\"\n    # Ensure the logs directory exists\n    log_dir = os.path.dirname(LOG_FILE)\n    os.makedirs(log_dir, exist_ok=True)\n\n    # Create logger\n    logger = logging.getLogger(\"imagesorcery\")\n    logger.setLevel(LOG_LEVEL)\n\n    # Prevent adding multiple handlers if setup is called more than once\n    if not logger.handlers:\n        # Create rotating file handler\n        handler = RotatingFileHandler(LOG_FILE, maxBytes=10*1024*1024, backupCount=5, encoding='utf-8')\n        # Change formatter to include module name and line number\n        formatter = logging.Formatter('%(asctime)s - %(name)s.%(module)s:%(lineno)d - %(levelname)s - %(message)s')\n        handler.setFormatter(formatter)\n        logger.addHandler(handler)\n\n        # Optional: Add a console handler for development/debugging\n        console_handler = logging.StreamHandler()\n        console_handler.setFormatter(formatter)\n        console_handler.setLevel(LOG_LEVEL) # Set level for console handler\n        logger.addHandler(console_handler)\n\n    print(f\"Log file: {LOG_FILE}\")\n    return logger\n\n# Setup logging when this module is imported\nsetup_logging()\n\n# Get the logger instance to be used in other modules\nlogger = logging.getLogger(\"imagesorcery\")\n"
  },
  {
    "path": "src/imagesorcery_mcp/middlewares/path_access.py",
    "content": "import logging\nimport os\nfrom pathlib import Path\nfrom typing import Any, Iterator\n\nfrom fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext\nfrom mcp import McpError\nfrom mcp.types import ErrorData\n\nAVAILABLE_PATHS_ENV = \"IMAGESORCERY_AVAILABLE_PATHS\"\n\n\nclass PathAccessMiddleware(Middleware):\n    \"\"\"Restrict tool file paths to configured directories.\"\"\"\n\n    def __init__(self, logger: logging.Logger | None = None):\n        self.logger = logger or logging.getLogger(\"imagesorcery.path_access\")\n\n    async def on_call_tool(\n        self,\n        context: MiddlewareContext,\n        call_next: CallNext,\n    ) -> Any:\n        allowed_dirs = get_allowed_directories()\n        if not allowed_dirs:\n            return await call_next(context)\n\n        arguments = getattr(context.message, \"arguments\", None) or {}\n        for argument_name, path_value in iter_path_arguments(arguments):\n            resolved_path = resolve_path(path_value)\n            if not is_path_allowed(resolved_path, allowed_dirs):\n                allowed = \", \".join(str(path) for path in allowed_dirs)\n                error_message = (\n                    f\"Path argument '{argument_name}' is outside allowed directories: \"\n                    f\"{resolved_path}. Allowed directories: {allowed}\"\n                )\n                self.logger.warning(error_message)\n                raise McpError(ErrorData(code=-32602, message=error_message))\n\n        return await call_next(context)\n\n\ndef get_allowed_directories() -> list[Path]:\n    raw_paths = os.getenv(AVAILABLE_PATHS_ENV, \"\")\n    if not raw_paths.strip():\n        return []\n\n    allowed_dirs = []\n    for raw_path in split_paths(raw_paths):\n        if not raw_path:\n            continue\n        allowed_dirs.append(resolve_path(raw_path))\n    return allowed_dirs\n\n\ndef split_paths(raw_paths: str) -> list[str]:\n    normalized = raw_paths.replace(\",\", os.pathsep)\n    return [part.strip() for part in normalized.split(os.pathsep) if part.strip()]\n\n\ndef iter_path_arguments(value: Any, prefix: str = \"\") -> Iterator[tuple[str, str]]:\n    if isinstance(value, dict):\n        for key, item in value.items():\n            name = f\"{prefix}.{key}\" if prefix else str(key)\n            if is_path_argument(str(key)) and isinstance(item, str) and item.strip():\n                yield name, item\n            else:\n                yield from iter_path_arguments(item, name)\n    elif isinstance(value, list):\n        for index, item in enumerate(value):\n            name = f\"{prefix}[{index}]\" if prefix else f\"[{index}]\"\n            yield from iter_path_arguments(item, name)\n\n\ndef is_path_argument(name: str) -> bool:\n    return name == \"path\" or name.endswith(\"_path\")\n\n\ndef resolve_path(path: str) -> Path:\n    return Path(os.path.abspath(os.path.expanduser(path)))\n\n\ndef is_path_allowed(path: Path, allowed_dirs: list[Path]) -> bool:\n    return any(path == allowed_dir or path.is_relative_to(allowed_dir) for allowed_dir in allowed_dirs)\n"
  },
  {
    "path": "src/imagesorcery_mcp/middlewares/telemetry.py",
    "content": "import logging\nimport sys\nfrom importlib.metadata import version\nfrom pathlib import Path\nfrom typing import Any\n\nfrom fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext\n\nfrom imagesorcery_mcp.config import get_config\nfrom imagesorcery_mcp.telemetry_amplitude import amplitude_handler\nfrom imagesorcery_mcp.telemetry_posthog import posthog_handler\n\n\nclass TelemetryMiddleware(Middleware):\n    \"\"\"Middleware that logs every tool, prompt, and resource run based on configuration.\"\"\"\n    \n    def __init__(self, logger: logging.Logger | None = None):\n        self.logger = logger or logging.getLogger(\"imagesorcery.telemetry\")\n        self.user_id = self._get_user_id() # Added user_id\n        self.version = self._get_version()\n        self.system = sys.platform\n\n        self.amplitude_handler = amplitude_handler\n        self.posthog_handler = posthog_handler\n\n    def _get_user_id(self) -> str:\n        \"\"\"Get user_id from .user_id file.\"\"\"\n        user_id_file = Path(\".user_id\")  # Path to .user_id in project root\n        self.logger.debug(f\"Looking for user ID file at: {user_id_file.absolute()}\")\n        try:\n            if user_id_file.exists():\n                user_id = user_id_file.read_text().strip()\n                if user_id:\n                    self.logger.debug(f\"User ID from file: {user_id}\")\n                    return user_id\n            self.logger.warning(\"User ID file not found or empty. Telemetry will use 'anonymous'.\")\n            return \"anonymous\"\n        except Exception as e:\n            self.logger.error(f\"Could not read user_id: {e}\")\n            return \"anonymous\"\n\n    def _get_version(self) -> str:\n        \"\"\"Get package version.\"\"\"\n        try:\n            package_version = version(\"imagesorcery-mcp\")\n            if package_version:\n                return package_version\n        except Exception:\n            pass\n\n        try:\n            import toml\n\n            pyproject_path = Path(__file__).resolve().parents[3] / \"pyproject.toml\"\n            return toml.load(pyproject_path)[\"project\"][\"version\"]\n        except Exception:\n            self.logger.warning(\"Could not determine package version for telemetry.\")\n            return \"unknown\"\n\n    async def _handle_action(self, action_type: str, identifier: str, context: MiddlewareContext, call_next: CallNext) -> Any:\n        \"\"\"Helper to log actions before and after execution, if telemetry is enabled.\"\"\"\n        self.logger.debug(f\"{action_type}: {identifier}\")\n        config = get_config()\n        self.logger.debug(f\"Telemetry enabled: {config.telemetry.enabled}\")\n\n        if not config.telemetry.enabled:\n            self.logger.debug(\"Telemetry enabled skipped\")\n            return await call_next(context)\n\n        log_data = {\n            \"user_id\": self.user_id, # Added user_id to log_data\n            \"version\": self.version,\n            \"system\": self.system,\n            \"action_type\": action_type.lower().replace(\" \", \"_\"),\n            \"identifier\": identifier,\n        }\n\n        try:\n            response = await call_next(context)\n            log_data[\"status\"] = \"success\"\n            self.logger.info(log_data)\n            self.posthog_handler.track_event(log_data)\n            self.amplitude_handler.track_event(log_data)\n            return response\n        except Exception:\n            log_data[\"status\"] = \"failed\"\n            self.logger.warning(log_data)\n            self.posthog_handler.track_event(log_data)\n            self.amplitude_handler.track_event(log_data)\n            raise\n\n    async def on_call_tool(self, context: MiddlewareContext, call_next: CallNext) -> Any:\n        \"\"\"Log tool calls before and after execution, if telemetry is enabled.\"\"\"\n        return await self._handle_action(\"Calling tool\", context.message.name, context, call_next)\n\n    async def on_read_resource(self, context: MiddlewareContext, call_next: CallNext) -> Any:\n        \"\"\"Log resource reads before and after execution, if telemetry is enabled.\"\"\"\n        return await self._handle_action(\"Reading resource\", str(context.message.uri), context, call_next)\n\n    async def on_get_prompt(self, context: MiddlewareContext, call_next: CallNext) -> Any:\n        \"\"\"Log prompt retrievals before and after execution, if telemetry is enabled.\"\"\"\n        return await self._handle_action(\"Getting prompt\", context.message.name, context, call_next)\n"
  },
  {
    "path": "src/imagesorcery_mcp/middlewares/validation.py",
    "content": "import logging\nimport re\nfrom typing import Any\n\nfrom fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext\nfrom mcp import McpError\nfrom mcp.types import ErrorData\n\n\nclass ImprovedValidationMiddleware(Middleware):\n    \"\"\"Middleware that improves validation error messages from FastMCP tools.\"\"\"\n    \n    def __init__(self, logger: logging.Logger | None = None):\n        self.logger = logger or logging.getLogger(\"imagesorcery.validation\")\n    \n    async def on_message(self, context: MiddlewareContext, call_next: CallNext) -> Any:\n        \"\"\"Handle messages with improved validation error reporting.\"\"\"\n        try:\n            return await call_next(context)\n        except Exception as e:\n            error_msg = str(e)\n            \n            if \"validation error for call[\" in error_msg:\n                tool_match = re.search(r'call\\[(\\w+)\\]', error_msg)\n                tool_name = tool_match.group(1) if tool_match else \"unknown\"\n                \n                errors = []\n                \n                if \"Unexpected keyword argument\" in error_msg:\n                    lines = error_msg.split('\\n')\n                    for i, line in enumerate(lines):\n                        if \"Unexpected keyword argument\" in line:\n                            if i > 0:\n                                param_line = lines[i-1].strip()\n                                param_name = param_line.split()[0] if param_line else \"unknown\"\n                                errors.append(f\"Unexpected parameter '{param_name}' - this parameter is not accepted by the tool '{tool_name}'\")\n                \n                if \"Missing required\" in error_msg:\n                    param_match = re.search(r\"Missing required.*?'(\\w+)'\", error_msg)\n                    if param_match:\n                        param_name = param_match.group(1)\n                        errors.append(f\"Missing required parameter '{param_name}'\")\n\n                invalid_value_match = re.search(r\"input_value='([^']+)'\", error_msg)\n                if invalid_value_match:\n                    invalid_value = invalid_value_match.group(1)\n                    errors.append(f\"Invalid value '{invalid_value}'\")\n                \n                if errors:\n                    error_message = \"Input validation error: \" + \"; \".join(errors)\n                else:\n                    error_message = f\"Input validation error in tool '{tool_name}': check that all parameters are correctly named and have the right types\"\n                \n                self.logger.error(error_message)\n                self.logger.debug(f\"Original error: {error_msg}\")\n                \n                raise McpError(\n                    ErrorData(code=-32602, message=error_message)\n                ) from e\n            \n            raise\n"
  },
  {
    "path": "src/imagesorcery_mcp/prompts/README.md",
    "content": "# Prompts\n\nThis directory contains reusable prompt templates for the ImageSorcery MCP server.\n\n## Overview\n\nPrompts provide parameterized message templates that help LLMs generate structured, purposeful responses for image processing workflows. Each prompt is designed to guide the AI through specific image manipulation tasks using the available tools.\n\n## Architecture\n\n- Register prompts by defining a `register_prompt` function in each prompt's module. This function should accept a `FastMCP` instance and use the `@mcp.prompt()` decorator to register the prompt function with the server. See `src/imagesorcery_mcp/server.py` for how prompts are imported and registered, and individual prompt files like `src/imagesorcery_mcp/prompts/remove_background.py` for examples of the `register_prompt` function implementation.\n- When adding new prompts, ensure they are listed in alphabetical order in READMEs and in the server registration.\n\n## Adding New Prompts\n\n1. Create a new Python file in this directory (e.g., `new_prompt.py`)\n2. Implement the prompt function with appropriate parameters and return type\n3. Create a `register_prompt` function that registers the prompt with the FastMCP instance\n4. Import and register the prompt in `src/imagesorcery_mcp/server.py`\n5. Add documentation to this README\n6. Write tests in `tests/prompts/test_new_prompt.py`\n\n## Available Prompts\n\n### `remove-background`\n\n**Description:** Guides the AI through a comprehensive background removal workflow using object detection and masking tools.\n\n**Parameters:**\n- `image_path` (str): Full path to the input image\n- `target_objects` (str, optional): Description of the objects to keep (default: empty for auto-detection)\n- `output_path` (str, optional): Path for the final result (default: auto-generated)\n\n**Example Usage:**\n```\nUse the remove-background prompt to remove the background from my photo 'portrait.jpg', keeping only the person\n```\n\n**Example Prompt Call (JSON):**\n```json\n{\n  \"name\": \"remove-background\",\n  \"arguments\": {\n    \"image_path\": \"/home/user/images/portrait.jpg\",\n    \"target_objects\": \"person\",\n    \"output_path\": \"/home/user/images/portrait_no_bg.png\"\n  }\n}\n```\n"
  },
  {
    "path": "src/imagesorcery_mcp/prompts/__init__.py",
    "content": "# Import the central logger\nfrom imagesorcery_mcp.logging_config import logger\n\nlogger.info(\"🪄 ImageSorcery MCP prompts package initialized\")\n"
  },
  {
    "path": "src/imagesorcery_mcp/prompts/remove_background.py",
    "content": "from typing import Annotated\n\nfrom fastmcp import FastMCP\nfrom pydantic import Field\n\n# Import the central logger\nfrom imagesorcery_mcp.logging_config import logger\n\n\ndef register_prompt(mcp: FastMCP):\n    @mcp.prompt(name=\"remove-background\")\n    def remove_background(\n        image_path: Annotated[\n            str, Field(description=\"Full path to the input image (must be a full path)\")\n        ],\n        target_objects: Annotated[\n            str,\n            Field(\n                description=\"Description of the objects to keep in the foreground (e.g., 'person', 'car and person', 'main subject')\"\n            ),\n        ] = \"\",\n        output_path: Annotated[\n            str,\n            Field(\n                description=\"Full path for the output image with background removed (optional, will auto-generate if not provided)\"\n            ),\n        ] = \"\",\n    ) -> str:\n        \"\"\"\n        Guides the AI through a comprehensive background removal workflow.\n        \n        This prompt provides a step-by-step approach to remove backgrounds from images\n        using object detection and masking tools. It's designed to work with the\n        ImageSorcery MCP server's detect and fill tools.\n        \n        The workflow includes:\n        1. Object detection to identify the target object\n        2. Mask generation for precise selection\n        3. Background removal using fill operations\n        4. Optional refinement steps\n        \n        Args:\n            image_path: Full path to the input image\n            target_objects: Description of what to keep (default: empty for auto-detection)\n            output_path: Where to save the result (auto-generated if empty)\n\n        Returns:\n            A detailed prompt guiding the AI through the background removal process\n        \"\"\"\n        logger.info(f\"Remove background prompt requested for image: {image_path}\")\n        logger.debug(f\"Target objects: {target_objects}, Output path: {output_path}\")\n        \n        # Generate output path if not provided\n        if not output_path:\n            if image_path.lower().endswith(('.png', '.jpg', '.jpeg')):\n                base_path = image_path.rsplit('.', 1)[0]\n                output_path = f\"{base_path}_no_background.png\"\n            else:\n                output_path = f\"{image_path}_no_background.png\"\n\n        # Build the prompt based on whether target_objects is specified\n        if target_objects:\n            prompt = f\"\"\"I need to remove the background from an image while preserving the {target_objects}. Please follow this step-by-step workflow:\n\n**Step 1: Find Target Objects**\nUse the `find` tool to locate the specific objects:\n- Call `find` on '{image_path}' with:\n  - description: \"{target_objects}\"\n  - confidence: 0.3 (lower threshold for better recall)\n  - return_geometry: true\n  - geometry_format: \"mask\"\n- This will use text-based object identification to locate the {target_objects}\n\n**Step 2: Remove Background**\nUse the `fill` tool to remove the background:\n- Call `fill` on '{image_path}' with:\n  - areas: Use mask files from find\n  - color: null\n  - output_path: '{output_path}'\n\n**Step 3: Clean Up**\n- Remove the temporary mask files created during the process\n\n**Important Notes:**\n- Save the final result as a PNG file to preserve transparency\n\nPlease execute this workflow step by step.\"\"\"\n        else:\n            prompt = f\"\"\"I need to remove the background from an image. Please follow this step-by-step workflow:\n\n**Step 1: Detect Objects**\nUse the `detect` tool to identify objects in the image:\n- Call `detect` on '{image_path}' with:\n  - confidence: 0.5 (to catch more objects)\n  - return_geometry: true\n  - geometry_format: \"mask\"\n- Review the detected objects and identify the main subjects to preserve\n\n**Step 3: Remove Background**\nUse the `fill` tool to remove the background:\n- Call `fill` on '{image_path}' with:\n  - areas: Use mask files from detect\n  - color: null\n  - output_path: '{output_path}'\n\n**Step 4: Clean Up**\n- Remove the temporary mask files created during the process\n\n**Important Notes:**\n- Save the final result as a PNG file to preserve transparency\n\nPlease execute this workflow step by step.\"\"\"\n\n        logger.info(f\"Generated remove background prompt for targets: {target_objects or 'auto-detect'}\")\n        return prompt\n"
  },
  {
    "path": "src/imagesorcery_mcp/resources/README.md",
    "content": "# 🪄 ImageSorcery MCP Server Resources Documentation\n\nThis document provides detailed information about each resource available in the 🪄 ImageSorcery MCP Server, including their URIs, descriptions, and examples of how to access them using a Claude client.\n\n## Rules\n\nThese rules apply to all contributors: humans and AI.\n\n- Register resources by defining a `register_resource` function in each resource's module. This function should accept a `FastMCP` instance and use the `@mcp.resource()` decorator to register the resource function with the server. See `src/imagesorcery_mcp/server.py` for how resources are imported and registered, and individual resource files like `src/imagesorcery_mcp/resources/models.py` for examples of the `register_resource` function implementation.\n- When adding new resources, ensure they are listed in alphabetical order in READMEs and in the server registration.\n\n\n## Available Resources\n\n### `models://list`\n\nLists all available models in the models directory. This resource scans the models directory and returns information about all available models, including their names, descriptions, and file paths.\n\n- **URI:** `models://list`\n- **Returns:** JSON string containing:\n  - `models`: List of available models, each with:\n    - `name`: Name of the model file (relative path from the models directory)\n    - `description`: Description of the model's purpose and characteristics\n    - `path`: Full path to the model file\n\n**Example Claude Request:**\n\n```\nList all available models in the models directory\n```\n\n**Example Resource Access (JSON):**\n\n```json\n{\n  \"resource\": \"models://list\"\n}\n```\n\n**Example Response (JSON):**\n\n```json\n{\n  \"models\": [\n    {\n      \"name\": \"yolov8m.pt\",\n      \"description\": \"YOLOv8 Medium - Default model with good balance between accuracy and speed.\",\n      \"path\": \"/path/to/models/yolov8m.pt\"\n    },\n    {\n      \"name\": \"yolov8n.pt\",\n      \"description\": \"YOLOv8 Nano - Smallest and fastest model, suitable for edge devices with limited resources.\",\n      \"path\": \"/path/to/models/yolov8n.pt\"\n    }\n  ]\n}"
  },
  {
    "path": "src/imagesorcery_mcp/resources/__init__.py",
    "content": "# Import the central logger\nfrom imagesorcery_mcp.logging_config import logger\n\nlogger.info(\"🪄 ImageSorcery MCP resources package initialized\")"
  },
  {
    "path": "src/imagesorcery_mcp/resources/models.py",
    "content": "import json\nfrom pathlib import Path\n\nfrom fastmcp import FastMCP\n\n# Import the central logger\nfrom imagesorcery_mcp.logging_config import logger\n\n\ndef get_model_description(model_name: str) -> str:\n    \"\"\"Get a description for a specific model.\"\"\"\n    logger.debug(f\"Attempting to get description for model: {model_name}\")\n    # Path to model descriptions JSON file\n    descriptions_file = Path(\"models\") / \"model_descriptions.json\"\n    \n    # Check if descriptions file exists\n    if not descriptions_file.exists():\n        logger.warning(f\"Model descriptions file not found: {descriptions_file}\")\n        return \"model_descriptions.json not found\"\n    \n    try:\n        # Load descriptions from JSON file\n        logger.debug(f\"Loading model descriptions from: {descriptions_file}\")\n        with open(descriptions_file, \"r\", encoding=\"utf-8\") as f:\n            descriptions = json.load(f)\n        logger.debug(f\"Loaded {len(descriptions)} model descriptions\")\n        \n        # Normalize model name to use forward slashes for consistent lookup\n        normalized_model_name = model_name.replace('\\\\', '/')\n        logger.debug(f\"Normalized model name for lookup: {normalized_model_name}\")\n        \n        # Try direct lookup and also case-insensitive lookup\n        if normalized_model_name in descriptions:\n            logger.debug(f\"Found direct match for model description: {normalized_model_name}\")\n            return descriptions[normalized_model_name]\n        \n        # Try case-insensitive lookup as a fallback\n        for key in descriptions:\n            if key.lower() == normalized_model_name.lower():\n                logger.debug(f\"Found case-insensitive match for model description: {key}\")\n                return descriptions[key]\n        \n        logger.warning(f\"Model '{model_name}' not found in model_descriptions.json (total descriptions: {len(descriptions)})\")\n        return f\"Model '{model_name}' not found in model_descriptions.json (total descriptions: {len(descriptions)})\"\n    except Exception as e:\n        # Return default description if any error occurs\n        logger.error(f\"Error in get_model_description for {model_name}: {str(e)}\", exc_info=True)\n        return \"model_descriptions.json parse issue\"\n\ndef register_resource(mcp: FastMCP):\n    @mcp.resource(\"models://list\")\n    async def list_models() -> str:\n        \"\"\"\n        List all available models in the models directory.\n\n        This resource provides information about all available models,\n        including their names and descriptions.\n        \"\"\"\n        logger.info(\"Models resource requested\")\n        models_dir = Path(\"models\")\n        available_models = []\n\n        # Check if models directory exists\n        if not models_dir.exists():\n            logger.warning(f\"Models directory not found: {models_dir}\")\n            return json.dumps({\"models\": available_models}, indent=2)\n        logger.info(f\"Scanning models directory: {models_dir}\")\n\n        # Define model file extensions to include\n        model_extensions = [\".pt\", \".pth\", \".onnx\", \".tflite\", \".pb\"]\n        logger.debug(f\"Looking for files with extensions: {model_extensions}\")\n\n        # Scan for model files recursively using rglob instead of glob\n        for file_path in models_dir.rglob(\"*\"):\n            if file_path.is_file() and file_path.suffix.lower() in model_extensions:\n                # Get relative path from models directory\n                rel_path = file_path.relative_to(models_dir)\n                # Convert to string with forward slashes for consistent naming across platforms\n                model_name = str(rel_path).replace('\\\\', '/')\n\n                description = get_model_description(model_name)\n                \n                available_models.append(\n                    {\n                        \"name\": model_name,\n                        \"description\": description,\n                        \"path\": str(file_path),\n                    }\n                )\n                logger.debug(f\"Found model: {model_name} with description: {description}\")\n        \n        logger.info(f\"Found {len(available_models)} available models\")\n        return json.dumps({\"models\": available_models}, indent=2)"
  },
  {
    "path": "src/imagesorcery_mcp/scripts/README.md",
    "content": "# 🪄 ImageSorcery MCP Server Scripts Documentation\n\nThis document provides detailed information about each script available in the 🪄 ImageSorcery MCP Server, including their purpose, arguments, and examples of how to use them.\n\n## Overview\n\nThe scripts directory contains utility scripts for model management and setup within the 🪄 ImageSorcery MCP Server. These scripts handle tasks such as:\n\n- `download-models`: Downloading YOLO models from various sources\n- `create-model-descriptions`: Creating model descriptions (used in `setup.sh`)\n- `download-clip-models`: Downloading CLIP models required for text-based detection (YOLOe *-pf models) (used in `setup.sh`)\n- `post-install-imagesorcery`: Running all post-installation tasks in a single command\n- `populate_telemetry_keys.py` / `clear_telemetry_keys.py`: build-time helpers for telemetry keys management\n\nThese scripts are typically run during project setup, packaging, or when adding new models to the system.\n\n## Common Functions\n\nThese scripts share some common functions and patterns:\n\n- All scripts use a central logger from `imagesorcery_mcp.logging_config`\n- They typically create the `models` directory if it doesn't exist\n- They handle existing files to avoid unnecessary downloads\n- Progress bars are provided for downloads using `tqdm`\n\n## Available Scripts\n\n### `download_models.py`\n\nDownloads YOLO compatible models for offline use from either Ultralytics or Hugging Face.\n\n- **Purpose:** Ensures that required detection models are available for tools like `detect` and `find`.\n- **Functionality:**\n  - Downloads models from Ultralytics repositories\n  - Downloads models from Hugging Face repositories\n  - Updates the model descriptions JSON file with information about downloaded models\n  - Organizes models in a proper directory structure\n- **Arguments:**\n  - `--ultralytics MODEL_NAME`: Download a model from Ultralytics (e.g., 'yolov8m.pt')\n  - `--huggingface REPO_ID[:FILENAME]`: Download a model from Hugging Face (e.g., 'username/repo:model.pt')\n\n**Command-line Usage:**\n```bash\n# Download from Ultralytics\ndownload-yolo-models --ultralytics yolov8m.pt\n\n# Download from Hugging Face\ndownload-yolo-models --huggingface ultralytics/yolov8:yolov8m.pt\n```\n\n**Python Import Usage:**\n```python\nfrom imagesorcery_mcp.scripts.download_models import download_ultralytics_model, download_from_huggingface\n\n# Download from Ultralytics\nsuccess = download_ultralytics_model('yolov8m.pt')\n\n# Download from Hugging Face\nsuccess = download_from_huggingface('ultralytics/yolov8:yolov8m.pt')\n```\n\n#### Notes\n\n- Downloaded models are stored in the `models` directory, which is included in `.gitignore` to prevent large model files from being committed to the repository.\n- If you encounter permission issues when running these scripts, ensure you have the necessary write access to the project directory.\n\n### `create_model_descriptions.py`\n\nCreates a JSON file containing descriptions for various detection models in the models directory.\n\n- **Purpose:** Ensures that model description information is available for reference by tools and users.\n- **Functionality:** \n  - Creates a comprehensive list of model descriptions for various YOLO models (YOLOv8, YOLO11, YOLO-NAS, etc.)\n  - Merges new descriptions with any existing ones, preserving custom descriptions\n  - Writes the merged descriptions to `models/model_descriptions.json`\n- **Usage:** Run directly or through the provided command-line entry point.\n\n**Command-line Usage:**\n```bash\ncreate-model-descriptions\n```\n\n**Python Import Usage:**\n```python\nfrom imagesorcery_mcp.scripts.create_model_descriptions import create_model_descriptions\n\n# Create the model descriptions file\nresult_path = create_model_descriptions()\n```\n\n### `download_clip.py`\n\nDownloads the MobileCLIP model required for YOLOe text prompts functionality.\n\n- **Purpose:** Ensures that the required MobileCLIP model is available for text-based detection in the `find` tool.\n- **Functionality:**\n  - Downloads the MobileCLIP model required for YOLOe text prompts\n  - Places the model in the root directory where it's expected by the find tool\n- **Usage:** Run directly or through the provided command-line entry point.\n\n**Command-line Usage:**\n```bash\ndownload-clip-models\n```\n\n**Python Import Usage:**\n```python\nfrom imagesorcery_mcp.scripts.download_clip import download_clip_model\n\n# Download CLIP model\nsuccess = download_clip_model()\n```\n\n### `post_install.py`\n\nRuns all post-installation tasks for the ImageSorcery MCP server in a single command.\n\n- **Purpose:** Automates the complete setup process after package installation.\n- **Functionality:**\n  - Creates the models directory\n  - Generates the model descriptions file with `create-model-descriptions`\n  - Downloads default YOLO models (yoloe-11l-seg-pf.pt, yoloe-11s-seg-pf.pt, yoloe-11l-seg.pt, yoloe-11s-seg.pt) with `download-yolo-models`\n  - Installs the `clip` Python package from Ultralytics' GitHub repository.\n  - Downloads the required CLIP model file for text prompts with `download-clip-models`.\n  - Ensures a `.user_id` file exists in project root (used for telemetry user identification).\n- **Usage:** Run directly, through the server with the `--post-install` flag, or through the provided command-line entry point.\n\n**Command-line Usage:**\n```bash\n# Run post-installation as a standalone script\npython -m src.imagesorcery_mcp.scripts.post_install\n\n# Or run it through the server with the --post-install flag\nimagesorcery-mcp --post-install\n```\n\n**Python Import Usage:**\n```python\nfrom imagesorcery_mcp.scripts.post_install import run_post_install\n\n# Run all post-installation tasks\nsuccess = run_post_install()\nif success:\n    print(\"Post-installation completed successfully!\")\nelse:\n    print(\"Post-installation failed.\")\n```\n\n## Telemetry Keys Management (build-time)\n\nTelemetry keys are no longer stored in `telemetry.toml`. Instead, telemetry API keys are managed via a small Python module and/or environment variables:\n\n- Telemetry user identifier is stored in `.user_id` (created by `post_install.py`).\n- API keys are provided either via environment variables or the Python module:\n  - Environment variables (preferred during build/deploy):\n    - `IMAGESORCERY_AMPLITUDE_API_KEY`\n    - `IMAGESORCERY_POSTHOG_API_KEY`\n  - Fallback module (kept in the repository as empty defaults): `src/imagesorcery_mcp/telemetry_keys.py`\n    - Contains `AMPLITUDE_API_KEY = \"\"` and `POSTHOG_API_KEY = \"\"`\n\nRationale:\n- `telemetry.toml` was unreliable in some packaging/build scenarios (it could be omitted from final artifacts). Using environment variables (and a small Python module as a fallback) ensures keys are available at runtime and during build without embedding secrets in the repo.\n\n### `populate_telemetry_keys.py`\n\n**Purpose**: Populate `src/imagesorcery_mcp/telemetry_keys.py` with API keys from the environment (or `.env`) during build time if desired.\n\n**Functionality**:\n- Reads `IMAGESORCERY_AMPLITUDE_API_KEY` and `IMAGESORCERY_POSTHOG_API_KEY` from environment variables (or `.env` when python-dotenv is available)\n- Writes these values into `src/imagesorcery_mcp/telemetry_keys.py`\n- Intended to be used in CI/build pipelines where keys are injected as environment variables before packaging\n\n**Command-line Usage**:\n```bash\npython -m src.imagesorcery_mcp.scripts.populate_telemetry_keys\n```\n\n**Notes**:\n- The script will not commit changes; CI should handle any necessary cleanup.\n- To skip population, set `SKIP_TELEMETRY_POPULATION=true`.\n\n### `clear_telemetry_keys.py`\n\n**Purpose**: Clear API keys in `src/imagesorcery_mcp/telemetry_keys.py` after build to keep the repository clean.\n\n**Functionality**:\n- Overwrites `src/imagesorcery_mcp/telemetry_keys.py` with empty string defaults:\n  ```py\n  AMPLITUDE_API_KEY = \"\"\n  POSTHOG_API_KEY = \"\"\n  ```\n- Intended to be invoked as a post-build/cleanup step in CI\n\n**Command-line Usage**:\n```bash\npython -m src.imagesorcery_mcp.scripts.clear_telemetry_keys\n```\n\n### Recommended CI / Build Integration\n\nA suggested pipeline for safely using telemetry keys in CI:\n\n1. In CI, set environment variables:\n   - `IMAGESORCERY_AMPLITUDE_API_KEY` and `IMAGESORCERY_POSTHOG_API_KEY`\n2. Run the populate script:\n   - `python -m src.imagesorcery_mcp.scripts.populate_telemetry_keys`\n3. Build/package the project\n4. Run the clear script to remove keys from the working copy:\n   - `python -m src.imagesorcery_mcp.scripts.clear_telemetry_keys`\n5. Ensure the CI does not persist telemetry_keys.py with real keys in any artifact or commit.\n\n**Dependencies**:\n- `python-dotenv` (optional) — used by scripts to load a `.env` file when present\n\n**Error Handling**:\n- Scripts log errors and return non-zero exit codes on failure so CI can fail fast.\n"
  },
  {
    "path": "src/imagesorcery_mcp/scripts/__init__.py",
    "content": "# Import functions to make them available when importing the package\n# Import the central logger\nfrom imagesorcery_mcp.logging_config import logger\n\nfrom .create_model_descriptions import create_model_descriptions\nfrom .download_models import download_model\n\n__all__ = [\"create_model_descriptions\", \"download_model\", \"logger\"]\n"
  },
  {
    "path": "src/imagesorcery_mcp/scripts/clear_telemetry_keys.py",
    "content": "#!/usr/bin/env python3\n\"\"\"Build script to clear API keys in src/imagesorcery_mcp/telemetry_keys.py while preserving .user_id.\"\"\"\n\nimport logging\nimport sys\nfrom pathlib import Path\n\n# Setup logging\nlogging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')\nlogger = logging.getLogger(__name__)\n\n# Telemetry keys file path\nTELEMETRY_KEYS_FILE = Path('src/imagesorcery_mcp/telemetry_keys.py')\n\ndef write_empty_telemetry_keys() -> bool:\n    \"\"\"Overwrite telemetry_keys.py with empty API key values.\"\"\"\n    try:\n        content = '''# Auto-generated telemetry keys module.\n# This file is intended to be updated by build scripts (populate_telemetry_keys.py)\n# and cleared by clear_telemetry_keys.py. Keep values as empty strings in the repo.\n#\n# WARNING: Do NOT commit real production keys to the repository.\n\nAMPLITUDE_API_KEY = \"\"\nPOSTHOG_API_KEY = \"\"\n'''\n        TELEMETRY_KEYS_FILE.write_text(content)\n        logger.info(f\"Cleared telemetry keys in {TELEMETRY_KEYS_FILE}\")\n        return True\n    except Exception as e:\n        logger.error(f\"Failed to clear telemetry keys file: {e}\")\n        return False\n\ndef main():\n    logger.info(\"Starting telemetry keys clearing process...\")\n\n    if not TELEMETRY_KEYS_FILE.exists():\n        logger.warning(f\"{TELEMETRY_KEYS_FILE} does not exist; creating a new cleared file.\")\n    if write_empty_telemetry_keys():\n        logger.info(\"Telemetry keys cleared successfully\")\n        return 0\n    else:\n        logger.error(\"Failed to clear telemetry keys\")\n        return 1\n\nif __name__ == '__main__':\n    sys.exit(main())\n"
  },
  {
    "path": "src/imagesorcery_mcp/scripts/create_model_descriptions.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nScript to create model descriptions JSON file.\nThis script should be run during project setup to ensure model descriptions are available.\n\"\"\"\n\nimport json\nimport os\nfrom pathlib import Path\n\n# Import the central logger\nfrom imagesorcery_mcp.logging_config import logger\n\n\ndef create_model_descriptions():\n    \"\"\"Create a JSON file with model descriptions in the models directory.\"\"\"\n    logger.info(f\"Creating model descriptions JSON file at {Path('models') / 'model_descriptions.json'}\")\n    # YOLOv8 model descriptions\n    model_descriptions = {\n        \"yolo11n.pt\": \"Ultralytics YOLO11 model for Object Detection. Provides state-of-the-art performance, suitable for tasks requiring a balance of speed and accuracy (smallest of YOLO11).\",\n        \"yolo11s.pt\": \"Ultralytics YOLO11 model for Object Detection. Provides state-of-the-art performance, more accurate than 'n', with slightly lower speed.\",\n        \"yolo11m.pt\": \"Ultralytics YOLO11 model for Object Detection. Provides state-of-the-art performance, a medium option balancing accuracy and speed.\",\n        \"yolo11l.pt\": \"Ultralytics YOLO11 model for Object Detection. Provides state-of-the-art performance, more accurate than 'm', with slightly lower speed.\",\n        \"yolo11x.pt\": \"Ultralytics YOLO11 model for Object Detection. Provides state-of-the-art performance (highest accuracy of YOLO11 Detect).\",\n        \"yolo11n-seg.pt\": \"Ultralytics YOLO11 model for Instance Segmentation. Provides state-of-the-art performance, smallest and fastest of YOLO11 Seg.\",\n        \"yolo11s-seg.pt\": \"Ultralytics YOLO11 model for Instance Segmentation. Provides state-of-the-art performance, a larger variant than 'n'.\",\n        \"yolo11m-seg.pt\": \"Ultralytics YOLO11 model for Instance Segmentation. Provides state-of-the-art performance, a medium variant.\",\n        \"yolo11l-seg.pt\": \"Ultralytics YOLO11 model for Instance Segmentation. Provides state-of-the-art performance, a larger variant than 'm'.\",\n        \"yolo11x-seg.pt\": \"Ultralytics YOLO11 model for Instance Segmentation. Provides state-of-the-art performance (highest accuracy of YOLO11 Seg).\",\n        \"yolo11n-pose.pt\": \"Ultralytics YOLO11 model for Pose Estimation / Keypoints detection. Provides state-of-the-art performance, smallest and fastest of YOLO11 Pose.\",\n        \"yolo11s-pose.pt\": \"Ultralytics YOLO11 model for Pose Estimation / Keypoints detection. Provides state-of-the-art performance, a larger variant than 'n'.\",\n        \"yolo11m-pose.pt\": \"Ultralytics YOLO11 model for Pose Estimation / Keypoints detection. Provides state-of-the-art performance, a medium variant.\",\n        \"yolo11l-pose.pt\": \"Ultralytics YOLO11 model for Pose Estimation / Keypoints detection. Provides state-of-the-art performance, a larger variant than 'm'.\",\n        \"yolo11x-pose.pt\": \"Ultralytics YOLO11 model for Pose Estimation / Keypoints detection. Provides state-of-the-art performance (highest accuracy of YOLO11 Pose).\",\n        \"yolo11n-obb.pt\": \"Ultralytics YOLO11 model for Oriented Object Detection (OBB). Provides state-of-the-art performance, smallest of YOLO11 OBB.\",\n        \"yolo11s-obb.pt\": \"Ultralytics YOLO11 model for Oriented Object Detection (OBB). Provides state-of-the-art performance, a larger variant than 'n'.\",\n        \"yolo11m-obb.pt\": \"Ultralytics YOLO11 model for Oriented Object Detection (OBB). Provides state-of-the-art performance, a medium variant.\",\n        \"yolo11l-obb.pt\": \"Ultralytics YOLO11 model for Oriented Object Detection (OBB). Provides state-of-the-art performance, a larger variant than 'm'.\",\n        \"yolo11x-obb.pt\": \"Ultralytics YOLO11 model for Oriented Object Detection (OBB). Provides state-of-the-art performance (highest accuracy of YOLO11 OBB).\",\n        \"yolo11n-cls.pt\": \"Ultralytics YOLO11 model for Image Classification. Provides state-of-the-art performance, smallest of YOLO11 Classify.\",\n        \"yolo11s-cls.pt\": \"Ultralytics YOLO11 model for Image Classification. Provides state-of-the-art performance, a larger variant than 'n'.\",\n        \"yolo11m-cls.pt\": \"Ultralytics YOLO11 model for Image Classification. Provides state-of-the-art performance, a medium variant.\",\n        \"yolo11l-cls.pt\": \"Ultralytics YOLO11 model for Image Classification. Provides state-of-the-art performance, a larger variant than 'm'.\",\n        \"yolo11x-cls.pt\": \"Ultralytics YOLO11 model for Image Classification. Provides state-of-the-art performance (highest accuracy of YOLO11 Classify).\",\n        \"yolov8n.pt\": \"General-purpose real-time Ultralytics YOLOv8 model for Object Detection. Provides a good balance of accuracy and speed, suitable for resource-constrained tasks (smallest of YOLOv8 Detect).\",\n        \"yolov8s.pt\": \"General-purpose real-time Ultralytics YOLOv8 model for Object Detection. Balances accuracy and speed, a larger variant than 'n'.\",\n        \"yolov8m.pt\": \"General-purpose real-time Ultralytics YOLOv8 model for Object Detection. Balances accuracy and speed, a medium variant.\",\n        \"yolov8l.pt\": \"General-purpose real-time Ultralytics YOLOv8 model for Object Detection. Balances accuracy and speed, a larger variant than 'm'.\",\n        \"yolov8x.pt\": \"General-purpose real-time Ultralytics YOLOv8 model for Object Detection. Balances accuracy and speed (highest accuracy of YOLOv8 Detect).\",\n        \"yolov8n-seg.pt\": \"General-purpose real-time Ultralytics YOLOv8 model for Instance Segmentation. Provides a good balance of accuracy and speed, suitable for resource-constrained tasks (smallest of YOLOv8 Seg).\",\n        \"yolov8s-seg.pt\": \"General-purpose real-time Ultralytics YOLOv8 model for Instance Segmentation. Balances accuracy and speed, a larger variant than 'n'.\",\n        \"yolov8m-seg.pt\": \"General-purpose real-time Ultralytics YOLOv8 model for Instance Segmentation. Balances accuracy and speed, a medium variant.\",\n        \"yolov8l-seg.pt\": \"General-purpose real-time Ultralytics YOLOv8 model for Instance Segmentation. Balances accuracy and speed, a larger variant than 'm'.\",\n        \"yolov8x-seg.pt\": \"General-purpose real-time Ultralytics YOLOv8 model for Instance Segmentation. Balances accuracy and speed (highest accuracy of YOLOv8 Seg).\",\n        \"yolov8n-pose.pt\": \"General-purpose real-time Ultralytics YOLOv8 model for Pose Estimation / Keypoints detection. Suitable for resource-constrained tasks (smallest of YOLOv8 Pose).\",\n        \"yolov8s-pose.pt\": \"General-purpose real-time Ultralytics YOLOv8 model for Pose Estimation / Keypoints detection. A larger variant than 'n'.\",\n        \"yolov8m-pose.pt\": \"General-purpose real-time Ultralytics YOLOv8 model for Pose Estimation / Keypoints detection. A medium variant.\",\n        \"yolov8l-pose.pt\": \"General-purpose real-time Ultralytics YOLOv8 model for Pose Estimation / Keypoints detection. A larger variant than 'm'.\",\n        \"yolov8x-pose.pt\": \"General-purpose real-time Ultralytics YOLOv8 model for Pose Estimation / Keypoints detection. The largest variant.\",\n        \"yolov8x-pose-p6.pt\": \"General-purpose real-time Ultralytics YOLOv8 model for Pose Estimation / Keypoints detection. Trained with 1280 input size.\",\n        \"yolov8n-obb.pt\": \"General-purpose real-time Ultralytics YOLOv8 model for Oriented Object Detection (OBB). Suitable for resource-constrained tasks (smallest of YOLOv8 OBB).\",\n        \"yolov8s-obb.pt\": \"General-purpose real-time Ultralytics YOLOv8 model for Oriented Object Detection (OBB). A larger variant than 'n'.\",\n        \"yolov8m-obb.pt\": \"General-purpose real-time Ultralytics YOLOv8 model for Oriented Object Detection (OBB). A medium variant.\",\n        \"yolov8l-obb.pt\": \"General-purpose real-time Ultralytics YOLOv8 model for Oriented Object Detection (OBB). A larger variant than 'm'.\",\n        \"yolov8x-obb.pt\": \"General-purpose real-time Ultralytics YOLOv8 model for Oriented Object Detection (OBB). The largest variant.\",\n        \"yolov8n-cls.pt\": \"General-purpose real-time Ultralytics YOLOv8 model for Image Classification. Suitable for resource-constrained tasks (smallest of YOLOv8 Classify).\",\n        \"yolov8s-cls.pt\": \"General-purpose real-time Ultralytics YOLOv8 model for Image Classification. A larger variant than 'n'.\",\n        \"yolov8m-cls.pt\": \"General-purpose real-time Ultralytics YOLOv8 model for Image Classification. A medium variant.\",\n        \"yolov8l-cls.pt\": \"General-purpose real-time Ultralytics YOLOv8 model for Image Classification. A larger variant than 'm'.\",\n        \"yolov8x-cls.pt\": \"General-purpose real-time Ultralytics YOLOv8 model for Image Classification. The largest variant.\",\n        \"rtdetr-l.pt\": \"Realtime Detection Transformer (RT-DETR) by Baidu for Object Detection. Well-suited for applications requiring high accuracy and real-time performance (smaller variant).\",\n        \"rtdetr-x.pt\": \"Realtime Detection Transformer (RT-DETR) by Baidu for Object Detection. Well-suited for applications requiring high accuracy and real-time performance (larger variant).\",\n        \"sam_b.pt\": \"Segment Anything Model (SAM) by Meta. Provides unique automatic segmentation capabilities based on prompts.\",\n        \"sam2_t.pt\": \"Segment Anything Model 2 (SAM2) by Meta. The next generation of SAM for video and images, provides automatic segmentation capabilities (smaller variant).\",\n        \"sam2_b.pt\": \"Segment Anything Model 2 (SAM2) by Meta. The next generation of SAM for video and images, provides automatic segmentation capabilities (larger variant).\",\n        \"mobile_sam.pt\": \"Mobile Segment Anything Model (MobileSAM). A mobile variant of SAM for segmentation.\",\n        \"FastSAM-s.pt\": \"Fast Segment Anything Model (FastSAM). A segmentation model that is faster and more efficient than SAM. Supports segmentation based on text prompts or bounding boxes.\",\n        \"yolo_nas_s.pt\": \"YOLO-NAS model (based on Neural Architecture Search) for Object Detection. Optimized for resource-constrained environments and focused on efficiency (smallest).\",\n        \"yolo_nas_m.pt\": \"YOLO-NAS model (based on Neural Architecture Search) for Object Detection. Offers a balanced approach, suitable for general object detection with higher accuracy (medium).\",\n        \"yolo_nas_l.pt\": \"YOLO-NAS model (based on Neural Architecture Search) for Object Detection. Designed for scenarios requiring the highest accuracy, where computational resources are less constrained (largest).\",\n        \"yolov8s-world.pt\": \"YOLO-World model (based on YOLOv8) for Real-Time Open-Vocabulary Object Detection. Detects any objects based on text descriptions, effective for zero-shot tasks (smaller variant, no export support).\",\n        \"yolov8s-worldv2.pt\": \"YOLO-World V2 model (based on YOLOv8) for Real-Time Open-Vocabulary Object Detection. Detects any objects based on text descriptions, effective for zero-shot tasks (smaller variant, with export support and deterministic training). Recommended for custom training.\",\n        \"yolov8m-world.pt\": \"YOLO-World model (based on YOLOv8) for Real-Time Open-Vocabulary Object Detection. Detects any objects based on text descriptions, effective for zero-shot tasks (medium variant, no export support).\",\n        \"yolov8m-worldv2.pt\": \"YOLO-World V2 model (based on YOLOv8) for Real-Time Open-Vocabulary Object Detection. Detects any objects based on text descriptions, effective for zero-shot tasks (medium variant, with export support and deterministic training).\",\n        \"yolov8l-world.pt\": \"YOLO-World model (based on YOLOv8) for Real-Time Open-Vocabulary Object Detection. Detects any objects based on text descriptions, effective for zero-shot tasks (larger variant, no export support).\",\n        \"yolov8l-worldv2.pt\": \"YOLO-World V2 model (based on YOLOv8) for Real-Time Open-Vocabulary Object Detection. Detects any objects based on text descriptions, effective for zero-shot tasks (larger variant, with export support and deterministic training).\",\n        \"yolov8x-world.pt\": \"YOLO-World model (based on YOLOv8) for Real-Time Open-Vocabulary Object Detection. Detects any objects based on text descriptions, effective for zero-shot tasks (largest variant, no export support).\",\n        \"yolov8x-worldv2.pt\": \"YOLO-World V2 model (based on YOLOv8) for Real-Time Open-Vocabulary Object Detection. Detects any objects based on text descriptions, effective for zero-shot tasks (largest variant, with export support and deterministic training).\",\n        \"yoloe-11s-seg.pt\": \"Real-Time Open-Vocabulary YOLOE model for Instance Segmentation. Detects arbitrary classes using text/visual prompts (smallest).\",\n        \"yoloe-11m-seg.pt\": \"Real-Time Open-Vocabulary YOLOE model for Instance Segmentation. Detects arbitrary classes using text/visual prompts (medium).\",\n        \"yoloe-11l-seg.pt\": \"Real-Time Open-Vocabulary YOLOE model for Instance Segmentation. Detects arbitrary classes using text/visual prompts (largest).\",\n        \"yoloe-v8s-seg.pt\": \"Real-Time Open-Vocabulary YOLOE model (based on YOLOv8) for Instance Segmentation. Detects arbitrary classes using text/visual prompts (smallest).\",\n        \"yoloe-v8m-seg.pt\": \"Real-Time Open-Vocabulary YOLOE model (based on YOLOv8) for Instance Segmentation. Detects arbitrary classes using text/visual prompts (medium).\",\n        \"yoloe-v8l-seg.pt\": \"Real-Time Open-Vocabulary YOLOE model (based on YOLOv8) for Instance Segmentation. Detects arbitrary classes using text/visual prompts (largest).\",\n        \"yoloe-11s-seg-pf.pt\": \"Real-Time Open-Vocabulary Prompt-Free YOLOE model for Instance Segmentation. Detects objects from a large built-in vocabulary (smallest).\",\n        \"yoloe-11m-seg-pf.pt\": \"Real-Time Open-Vocabulary Prompt-Free YOLOE model for Instance Segmentation. Detects objects from a large built-in vocabulary (medium).\",\n        \"yoloe-11l-seg-pf.pt\": \"Real-Time Open-Vocabulary Prompt-Free YOLOE model for Instance Segmentation. Detects objects from a large built-in vocabulary (largest).\",\n        \"yoloe-v8s-seg-pf.pt\": \"Real-Time Open-Vocabulary Prompt-Free YOLOE model (based on YOLOv8) for Instance Segmentation. Detects objects from a large built-in vocabulary (smallest).\",\n        \"yoloe-v8m-seg-pf.pt\": \"Real-Time Open-Vocabulary Prompt-Free YOLOE model (based on YOLOv8) for Instance Segmentation. Detects objects from a large built-in vocabulary (medium).\",\n        \"yoloe-v8l-seg-pf.pt\": \"Real-Time Open-Vocabulary Prompt-Free YOLOE model (based on YOLOv8) for Instance Segmentation. Detects objects from a large built-in vocabulary (largest).\",\n        \"yolov10n.pt\": \"Real-Time End-to-End YOLOv10 model for Object Detection. Suitable for very resource-constrained environments (smallest).\",\n        \"yolov10s.pt\": \"Real-Time End-to-End YOLOv10 model for Object Detection. Balances speed and accuracy.\",\n        \"yolov10m.pt\": \"Real-Time End-to-End YOLOv10 model for Object Detection. Suitable for general use (medium).\",\n        \"yolov10l.pt\": \"Real-Time End-to-End YOLOv10 model for Object Detection. High accuracy at the cost of computational resources.\",\n        \"yolov10x.pt\": \"Real-Time End-to-End YOLOv10 model for Object Detection. Maximum accuracy and performance (largest).\",\n        \"yolov3u.pt\": \"YOLOv3 model for Object Detection. An older but effective real-time model.\",\n        \"yolov3-tinyu.pt\": \"YOLOv3-Tiny model for Object Detection. A very fast, lightweight version of YOLOv3.\",\n        \"yolov3-sppu.pt\": \"YOLOv3-SPP model for Object Detection. A version of YOLOv3 with an SPP module for improved performance.\",\n        \"yolov9t.pt\": \"YOLOv9 model for Object Detection. Uses PGI for data preservation, useful for lightweight models (smallest of YOLOv9 Detect).\",\n        \"yolov9s.pt\": \"YOLOv9 model for Object Detection. Uses PGI for data preservation, useful for lightweight models (a larger variant than 't').\",\n        \"yolov9m.pt\": \"YOLOv9 model for Object Detection. Uses PGI for data preservation, useful for lightweight models (medium).\",\n        \"yolov9c.pt\": \"YOLOv9 model for Object Detection. Uses PGI for data preservation, useful for lightweight models (a larger variant than 'm').\",\n        \"yolov9e.pt\": \"YOLOv9 model for Object Detection. Uses PGI for data preservation, useful for lightweight models (largest of YOLOv9 Detect).\",\n        \"yolov9c-seg.pt\": \"YOLOv9 model for Instance Segmentation. Uses PGI for data preservation, useful for lightweight models (smaller variant).\",\n        \"yolov9e-seg.pt\": \"YOLOv9 model for Instance Segmentation. Uses PGI for data preservation, useful for lightweight models (larger variant).\",\n        \"yolo12n.pt\": \"YOLO12 'Attention-Centric' model for Object Detection. (Example provided only for the 'n' variant).\"\n    }\n\n    # Create models directory if it doesn't exist\n    models_dir = Path(\"models\").resolve()\n    os.makedirs(models_dir, exist_ok=True)\n    logger.info(f\"Ensured models directory exists: {models_dir}\")\n\n    descriptions_file = models_dir / \"model_descriptions.json\"\n    existing_descriptions = {}\n\n    # Read existing descriptions if the file exists\n    if descriptions_file.exists():\n        try:\n            with open(descriptions_file, \"r\") as f:\n                existing_descriptions = json.load(f)\n            logger.info(f\"Loaded existing model descriptions from: {descriptions_file}\")\n        except json.JSONDecodeError:\n            logger.warning(f\"Error decoding JSON from {descriptions_file}, starting with empty descriptions.\")\n            existing_descriptions = {}\n        except Exception as e:\n            logger.error(f\"Error reading existing model descriptions from {descriptions_file}: {e}\")\n            existing_descriptions = {}\n\n    # Merge new descriptions with existing ones\n    # Existing descriptions take precedence to avoid overwriting custom ones\n    merged_descriptions = model_descriptions.copy()\n    merged_descriptions.update(existing_descriptions)\n\n    # Write merged descriptions to JSON file\n    logger.info(f\"Writing merged model descriptions to: {descriptions_file}\")\n    try:\n        with open(descriptions_file, \"w\") as f:\n            json.dump(merged_descriptions, f, indent=2)\n        logger.info(f\"Model descriptions updated successfully at: {descriptions_file}\")\n        print(f\"✅ Model descriptions updated at: {descriptions_file}\")\n        return str(descriptions_file)\n    except Exception as e:\n        logger.error(f\"Error writing merged model descriptions to {descriptions_file}: {e}\")\n        print(f\"❌ Failed to update model descriptions at: {descriptions_file}\")\n        return None\n\n\ndef main():\n    logger.info(f\"Running create_model_descriptions script from {Path(__file__).resolve()}\")\n    create_model_descriptions()\n    logger.info(\"create_model_descriptions script finished\")\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "src/imagesorcery_mcp/scripts/download_clip.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nScript to download CLIP models required for YOLOe text prompts.\n\"\"\"\n\nimport os\nimport sys\nfrom pathlib import Path\n\nimport requests\nfrom tqdm import tqdm\n\n# Import the central logger\nfrom imagesorcery_mcp.logging_config import logger\n\n\ndef get_models_dir():\n    \"\"\"Get the models directory in the project root.\"\"\"\n    models_dir = Path(\"models\").resolve()\n    os.makedirs(models_dir, exist_ok=True)\n    logger.info(f\"Ensured models directory exists: {models_dir}\")\n    return models_dir\n\n\ndef download_file(url, output_path):\n    \"\"\"Download a file from a URL with progress bar.\"\"\"\n    logger.info(f\"Attempting to download file from {url} to {output_path}\")\n    try:\n        response = requests.get(url, stream=True)\n        response.raise_for_status()\n        \n        total_size = int(response.headers.get('content-length', 0))\n        block_size = 1024  # 1 Kibibyte\n        \n        with open(output_path, 'wb') as file, tqdm(\n            desc=f\"Downloading to {os.path.basename(output_path)}\",\n            total=total_size,\n            unit='iB',\n            unit_scale=True,\n            unit_divisor=1024,\n        ) as bar:\n            for data in response.iter_content(block_size):\n                size = file.write(data)\n                bar.update(size)\n        \n        logger.info(f\"Successfully downloaded file to {output_path}\")\n        return True\n    except Exception as e:\n        logger.error(f\"Error downloading from {url}: {str(e)}\")\n        return False\n\n\ndef download_clip_model():\n    \"\"\"Download the MobileCLIP model required for YOLOe text prompts.\"\"\"\n    logger.info(\"Attempting to download CLIP model\")\n    root_clip_model_path = Path(\"mobileclip_blt.ts\").resolve()\n\n    # Check if model already exists in root directory\n    if root_clip_model_path.exists():\n        logger.info(f\"CLIP model already exists at: {root_clip_model_path}\")\n        return True\n\n    # URL for the MobileCLIP model\n    url = \"https://github.com/ultralytics/assets/releases/download/v8.3.0/mobileclip_blt.ts\"\n\n    # Download directly to root directory\n    logger.info(f\"Downloading CLIP model to root directory from: {url}\")\n    success = download_file(url, root_clip_model_path)\n    if success:\n        logger.info(f\"CLIP model successfully downloaded to: {root_clip_model_path}\")\n        return True\n    else:\n        logger.error(f\"Failed to download CLIP model to: {root_clip_model_path}\")\n        return False\n\n\ndef main():\n    \"\"\"Main function to download CLIP models.\"\"\"\n    logger.info(f\"Running download_clip_models script from {Path(__file__).resolve()}\")\n    \n    # Download the MobileCLIP model\n    if download_clip_model():\n        logger.info(\"CLIP model downloaded successfully\")\n        print(\"✅ CLIP model download completed successfully\")\n    else:\n        logger.error(\"Failed to download CLIP model\")\n        print(\"❌ Failed to download CLIP model\")\n        sys.exit(1)\n    \n    logger.info(\"download_clip_models script finished\")\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "src/imagesorcery_mcp/scripts/download_models.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nScript to download YOLO compatible models for offline use.\nThis script should be run during project setup to ensure models are available.\n\"\"\"\n\nimport argparse\nimport json\nimport os\nimport shutil\nimport sys\nfrom pathlib import Path\n\nimport requests\nfrom tqdm import tqdm\n\n# Import the central logger\nfrom imagesorcery_mcp.logging_config import logger\n\n\ndef get_models_dir():\n    \"\"\"Get the models directory in the project root.\"\"\"\n    models_dir = Path(\"models\").resolve()\n    os.makedirs(models_dir, exist_ok=True)\n    logger.info(f\"Ensured models directory exists: {models_dir}\")\n    return str(models_dir)\n\n\ndef download_from_url(url, output_path):\n    \"\"\"Download a file from a URL with progress bar.\"\"\"\n    logger.info(f\"Attempting to download file from {url} to {output_path}\")\n    try:\n        response = requests.get(url, stream=True)\n        response.raise_for_status()\n        \n        total_size = int(response.headers.get('content-length', 0))\n        block_size = 1024  # 1 Kibibyte\n        \n        with open(output_path, 'wb') as file, tqdm(\n            desc=f\"Downloading to {os.path.basename(output_path)}\",\n            total=total_size,\n            unit='iB',\n            unit_scale=True,\n            unit_divisor=1024,\n        ) as bar:\n            for data in response.iter_content(block_size):\n                size = file.write(data)\n                bar.update(size)\n        \n        logger.info(f\"Successfully downloaded file to {output_path}\")\n        return True\n    except Exception as e:\n        logger.error(f\"Error downloading from {url}: {str(e)}\")\n        return False\n\n\ndef download_from_huggingface(model_name):\n    \"\"\"Download a model from Hugging Face.\"\"\"\n    logger.info(f\"Attempting to download model from Hugging Face: {model_name}\")\n    # Extract repo_id and model filename\n    if \"/\" not in model_name:\n        logger.error(\"Invalid Hugging Face model format. Use 'username/repo:filename' or 'username/repo'\")\n        return False\n    \n    parts = model_name.split(\":\", 1)\n    repo_id = parts[0]\n    filename = parts[1] if len(parts) > 1 else None\n    \n    # Default description\n    model_description = f\"Model from Hugging Face repository: {repo_id}\"\n    \n    # Try to get model description\n    try:\n        from huggingface_hub import model_info\n        info = model_info(repo_id)\n        if info.cardData and \"model-index\" in info.cardData:\n            model_index = info.cardData[\"model-index\"]\n            if model_index and len(model_index) > 0 and \"name\" in model_index[0]:\n                model_description = model_index[0].get('name', model_description)\n                logger.info(f\"Fetched model description: {model_description}\")\n        elif info.description:\n            # Extract first line or first 100 characters of description\n            description = info.description.split('\\n')[0][:100]\n            if len(info.description) > 100:\n                description += \"...\"\n            model_description = description\n            logger.info(f\"Fetched model description: {model_description}\")\n    except Exception as e:\n        logger.warning(f\"Could not fetch model description: {str(e)}\")\n    \n    # If no specific filename provided, try to find a .pt file\n    if filename is None:\n        try:\n            from huggingface_hub import list_repo_files\n            files = list_repo_files(repo_id)\n            pt_files = [f for f in files if f.endswith('.pt')]\n            if not pt_files:\n                logger.error(f\"No .pt files found in {repo_id}\")\n                return False\n            filename = pt_files[0]\n            logger.info(f\"Found model file in repository: {filename}\")\n        except Exception as e:\n            logger.error(f\"Error listing files in repository: {str(e)}\")\n            return False\n    \n    # Create directory structure based on repo_id\n    models_dir = get_models_dir()\n    repo_dir = os.path.join(models_dir, repo_id.replace(\"/\", os.sep))\n    os.makedirs(repo_dir, exist_ok=True)\n    logger.info(f\"Ensured repository directory exists: {repo_dir}\")\n    \n    # Set the output path\n    output_filename = os.path.basename(filename)\n    output_path = os.path.join(repo_dir, output_filename)\n    \n    # Update model_descriptions.json with the model description\n    model_key = f\"{repo_id}/{output_filename}\"\n    update_model_description(model_key, model_description)\n    \n    # Check if model already exists\n    if os.path.exists(output_path):\n        logger.info(f\"Model already exists at: {output_path}\")\n        return True\n    \n    # Construct the download URL\n    url = f\"https://huggingface.co/{repo_id}/resolve/main/{filename}\"\n    \n    logger.info(f\"Downloading from Hugging Face: {repo_id}/{filename}\")\n    logger.info(f\"Saving to: {output_path}\")\n    return download_from_url(url, output_path)\n\n\ndef update_model_description(model_key, description):\n    \"\"\"Update the model_descriptions.json file with a new model description.\"\"\"\n    logger.info(f\"Updating model description for {model_key}\")\n    models_dir = get_models_dir()\n    descriptions_file = os.path.join(models_dir, \"model_descriptions.json\")\n    \n    # Load existing descriptions or create new if file doesn't exist\n    if os.path.exists(descriptions_file):\n        try:\n            with open(descriptions_file, 'r') as f:\n                descriptions = json.load(f)\n            logger.info(f\"Loaded existing model descriptions from {descriptions_file}\")\n        except json.JSONDecodeError:\n            logger.warning(\"Error reading model_descriptions.json, creating new file\")\n            descriptions = {}\n    else:\n        logger.info(\"model_descriptions.json not found, creating new file\")\n        descriptions = {}\n    \n    # Update the description for this model\n    if model_key not in descriptions:\n        descriptions[model_key] = description\n        logger.info(f\"Added description for {model_key} to model_descriptions.json\")\n    elif descriptions[model_key] != description:\n        descriptions[model_key] = description\n        logger.info(f\"Updated description for {model_key} in model_descriptions.json\")\n    else:\n        logger.info(f\"Description for {model_key} is already up to date\")\n    \n    # Save the updated descriptions\n    try:\n        with open(descriptions_file, 'w') as f:\n            json.dump(descriptions, f, indent=2, sort_keys=True)\n        logger.info(f\"Saved updated model descriptions to {descriptions_file}\")\n    except Exception as e:\n        logger.error(f\"Error updating model_descriptions.json: {str(e)}\")\n\ndef download_ultralytics_model(model_name):\n    \"\"\"Download a specific YOLO model from Ultralytics to the models directory.\"\"\"\n    logger.info(f\"Attempting to download Ultralytics model: {model_name}\")\n    try:\n        # Get the models directory\n        models_dir = get_models_dir()\n        \n        # Set the output path\n        output_path = os.path.join(models_dir, model_name)\n        \n        # Check if model already exists in models directory\n        if os.path.exists(output_path):\n            logger.info(f\"Model already exists at: {output_path}\")\n            return True\n\n        # Set environment variable to use the models directory\n        os.environ[\"YOLO_CONFIG_DIR\"] = models_dir\n        logger.info(f\"Set YOLO_CONFIG_DIR environment variable to: {models_dir}\")\n\n        # Import and download the model\n        from ultralytics import YOLO\n\n        logger.info(f\"Downloading {model_name} model using Ultralytics library...\")\n\n        # The model variable is used to trigger the download\n        model = YOLO(model_name)  # noqa: F841\n\n        # Check if the model was downloaded to the expected location\n        if os.path.exists(output_path):\n            logger.info(f\"Model successfully downloaded to expected path: {output_path}\")\n            return True\n\n        # Check if model was downloaded to current directory\n        current_dir_model = Path(model_name)\n        if current_dir_model.exists():\n            logger.info(f\"Model found in current directory: {current_dir_model.resolve()}\")\n            try:\n                # Move the model to the models directory\n                shutil.move(str(current_dir_model), output_path)\n                logger.info(f\"Model moved to: {output_path}\")\n                return True\n            except Exception as e:\n                logger.warning(f\"Could not move model from {current_dir_model.resolve()} to {output_path}: {e}\")\n                logger.info(f\"You can still use the model from: {current_dir_model.resolve()}\")\n                return True\n\n        # If not found in expected locations,\n        # try to find it in ultralytics default location\n        possible_locations = [\n            Path.home() / \".ultralytics\" / \"weights\" / model_name,\n            Path(os.path.dirname(os.path.abspath(__file__))) / \"weights\" / model_name,\n        ]\n\n        # Try to import ultralytics to find its location\n        try:\n            import ultralytics\n\n            ultralytics_dir = Path(ultralytics.__file__).parent\n            possible_locations.append(ultralytics_dir / \"weights\" / model_name)\n        except ImportError:\n            logger.warning(\"Could not import ultralytics to find default weights location\")\n\n        # Check each location\n        for loc in possible_locations:\n            if loc.exists():\n                logger.info(f\"Model found at a different location: {loc.resolve()}\")\n                try:\n                    shutil.copy(loc, output_path)\n                    logger.info(f\"Model copied to: {output_path}\")\n                    return True\n                except Exception as e:\n                    logger.warning(f\"Could not copy model from {loc.resolve()} to {output_path}: {e}\")\n                    logger.error(\n                        f\"Please manually copy the model from {loc.resolve()} to {output_path}\"\n                    )\n                    return False\n\n        logger.error(f\"Failed to download model to expected path: {output_path}\")\n        return False\n\n    except Exception as e:\n        logger.error(f\"Error downloading model: {str(e)}\")\n        return False\n\n\ndef download_model(model_name, source=None):\n    \"\"\"\n    Legacy function for backward compatibility.\n    Downloads a model from the specified source.\n    \"\"\"\n    logger.info(f\"Legacy download_model called for {model_name} from source {source}\")\n    if source == \"ultralytics\":\n        return download_ultralytics_model(model_name)\n    elif source == \"huggingface\":\n        return download_from_huggingface(model_name)\n    else:\n        logger.error(f\"Unknown model source: {source}\")\n        return False\n\n\ndef main():\n    logger.info(f\"Running download_models script from {Path(__file__).resolve()}\")\n    parser = argparse.ArgumentParser(\n        description=\"Download YOLO compatible models for offline use\"\n    )\n    \n    # Create a mutually exclusive group for the model sources\n    source_group = parser.add_mutually_exclusive_group(required=True)\n    source_group.add_argument(\n        \"--ultralytics\",\n        metavar=\"MODEL_NAME\",\n        help=\"Download a model from Ultralytics (e.g., 'yolov8m.pt')\"\n    )\n    source_group.add_argument(\n        \"--huggingface\",\n        metavar=\"REPO_ID[:FILENAME]\",\n        help=\"Download a model from Hugging Face (e.g., 'username/repo:model.pt' or 'username/repo')\"\n    )\n\n    args = parser.parse_args()\n    \n    if args.ultralytics:\n        logger.info(f\"Downloading Ultralytics model: {args.ultralytics}\")\n        success = download_ultralytics_model(args.ultralytics)\n    elif args.huggingface:\n        logger.info(f\"Downloading Hugging Face model: {args.huggingface}\")\n        success = download_from_huggingface(args.huggingface)\n    else:\n        # This should never happen due to the required=True in the mutually_exclusive_group\n        logger.error(\"No model source specified\")\n        success = False\n    \n    if not success:\n        logger.error(\"Model download failed\")\n        sys.exit(1)\n    else:\n        logger.info(\"Model download completed successfully\")\n\n    logger.info(\"download_models script finished\")\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "src/imagesorcery_mcp/scripts/populate_telemetry_keys.py",
    "content": "#!/usr/bin/env python3\n\"\"\"Build script to populate src/imagesorcery_mcp/telemetry_keys.py with API keys from environment variables or .env file.\"\"\"\n\nimport logging\nimport os\nimport sys\nfrom pathlib import Path\nfrom typing import Dict\n\ntry:\n    from dotenv import load_dotenv\n    DOTENV_AVAILABLE = True\nexcept ImportError:\n    DOTENV_AVAILABLE = False\n\n# Setup logging\nlogging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')\nlogger = logging.getLogger(__name__)\n\n# Telemetry keys file path\nTELEMETRY_KEYS_FILE = Path('src/imagesorcery_mcp/telemetry_keys.py')\n\ndef get_telemetry_keys() -> Dict[str, str]:\n    \"\"\"Get telemetry API keys from environment variables and .env file.\n\n    Priority order:\n    1. Environment variables\n    2. .env file\n\n    Returns:\n        Dictionary containing telemetry API keys\n    \"\"\"\n    keys = {}\n\n    # Load .env file if available\n    if DOTENV_AVAILABLE:\n        env_file = Path('.env')\n        if env_file.exists():\n            load_dotenv(env_file)\n            logger.debug(\"Loaded .env file\")\n\n    # Get Amplitude API key (env var takes priority)\n    amplitude_key = os.environ.get('IMAGESORCERY_AMPLITUDE_API_KEY', '')\n    keys['AMPLITUDE_API_KEY'] = amplitude_key\n    if amplitude_key:\n        logger.debug(\"Found IMAGESORCERY_AMPLITUDE_API_KEY\")\n\n    # Get PostHog API key (env var takes priority)\n    posthog_key = os.environ.get('IMAGESORCERY_POSTHOG_API_KEY', '')\n    keys['POSTHOG_API_KEY'] = posthog_key\n    if posthog_key:\n        logger.debug(\"Found IMAGESORCERY_POSTHOG_API_KEY\")\n\n    return keys\n\ndef write_telemetry_keys_file(keys: Dict[str, str]) -> bool:\n    \"\"\"Write the telemetry_keys.py file with provided keys.\n\n    Args:\n        keys: Dict with 'AMPLITUDE_API_KEY' and 'POSTHOG_API_KEY'\n\n    Returns:\n        True if successful, False otherwise\n    \"\"\"\n    try:\n        content = f'''# Auto-generated telemetry keys module.\n# This file is intended to be updated by build scripts (populate_telemetry_keys.py)\n# and cleared by clear_telemetry_keys.py. Keep values as empty strings in the repo.\n#\n# WARNING: Do NOT commit real production keys to the repository.\n\nAMPLITUDE_API_KEY = \"{keys.get(\"AMPLITUDE_API_KEY\", \"\")}\"\nPOSTHOG_API_KEY = \"{keys.get(\"POSTHOG_API_KEY\", \"\")}\"\n'''\n        TELEMETRY_KEYS_FILE.write_text(content)\n        logger.info(f\"Wrote telemetry keys to {TELEMETRY_KEYS_FILE}\")\n        return True\n    except Exception as e:\n        logger.error(f\"Failed to write telemetry keys file: {e}\")\n        return False\n\ndef main():\n    \"\"\"Main entry point to populate telemetry_keys.py.\"\"\"\n    logger.info(\"Starting telemetry keys population process...\")\n\n    # Optionally skip\n    if os.environ.get('SKIP_TELEMETRY_POPULATION', '').lower() in ('true', '1', 'yes'):\n        logger.info(\"Telemetry population skipped via SKIP_TELEMETRY_POPULATION environment variable\")\n        return 0\n\n    keys = get_telemetry_keys()\n\n    found_keys = [k for k, v in keys.items() if v]\n    empty_keys = [k for k, v in keys.items() if not v]\n\n    if found_keys:\n        logger.info(f\"Found telemetry keys: {', '.join(found_keys)}\")\n    if empty_keys:\n        logger.info(f\"Empty telemetry keys (will remain empty): {', '.join(empty_keys)}\")\n\n    if write_telemetry_keys_file(keys):\n        logger.info(\"Telemetry keys population completed successfully\")\n        return 0\n    else:\n        logger.error(\"Telemetry keys population failed\")\n        return 1\n\nif __name__ == '__main__':\n    sys.exit(main())\n"
  },
  {
    "path": "src/imagesorcery_mcp/scripts/post_install.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nScript to run post-installation tasks for imagesorcery-mcp.\nThis script creates the models directory, model descriptions file,\nand downloads default models.\n\"\"\"\n\nimport os\nimport subprocess  # Ensure subprocess is imported\nimport sys  # Ensure sys is imported\nimport uuid\nfrom pathlib import Path\n\n# Import the central logger\nfrom imagesorcery_mcp.logging_config import logger\n\n# For loading .env file\ntry:\n    from dotenv import load_dotenv\n    DOTENV_AVAILABLE = True\nexcept ImportError:\n    DOTENV_AVAILABLE = False\n    logger.warning(\"python-dotenv not available. .env file will not be loaded automatically.\")\nfrom imagesorcery_mcp.scripts.create_model_descriptions import create_model_descriptions\nfrom imagesorcery_mcp.scripts.download_clip import download_clip_model\nfrom imagesorcery_mcp.scripts.download_models import download_ultralytics_model\n\n\ndef install_clip():\n    \"\"\"Install CLIP from the Ultralytics GitHub repository.\"\"\"\n    logger.info(\"Installing CLIP package from GitHub...\")\n    \n    try:\n        subprocess.run(\n            [sys.executable, \"-m\", \"pip\", \"install\", \"git+https://github.com/ultralytics/CLIP.git\"],\n            check=True,\n            stdout=sys.stdout, # Can be replaced with subprocess.PIPE if console output is not needed\n            stderr=subprocess.PIPE  # Capture stderr to analyze it\n        )\n        logger.info(\"CLIP package installed successfully\")\n        print(\"✅ CLIP package installed successfully\")\n        return True\n    except subprocess.CalledProcessError as e:\n        logger.error(f\"Failed to install CLIP: {e}\")\n        error_message = f\"❌ Failed to install CLIP package: {e}\"\n        detailed_warning = \"\"\n        if e.stderr:\n            try:\n                stderr_output = e.stderr.decode(errors='ignore')\n                logger.debug(f\"Captured stderr from CLIP installation attempt: {stderr_output}\")\n                if \"No module named pip\" in stderr_output:\n                    detailed_warning = (\n                        \"\\n   Hint: The Python environment (potentially created by 'uvx' or a minimal 'uv venv') might be missing 'pip'.\"\n                        \"\\n   To ensure 'clip' package installation for full functionality (e.g., text prompts in 'find' tool):\"\n                        \"\\n     1. Recommended: Use 'python -m venv' to create a virtual environment, then 'pip install imagesorcery-mcp' and 'imagesorcery-mcp --post-install'.\"\n                        \"\\n     2. Or, manually install 'clip' into your active environment: pip install git+https://github.com/ultralytics/CLIP.git\"\n                        \"\\n        (If using 'uv venv', you might need: uv pip install git+https://github.com/ultralytics/CLIP.git)\"\n                    )\n            except Exception as decode_exc:\n                logger.error(f\"Error while decoding/processing stderr for CLIP install: {decode_exc}\")\n        \n        print(error_message + detailed_warning)\n        return False\n    except FileNotFoundError: # Handle case where pip or python executable is not found\n        logger.error(\"Failed to install CLIP: Python executable or pip not found.\")\n        print(\"❌ Failed to install CLIP package: Python executable or pip not found. Ensure Python is in PATH and pip is installed.\")\n        return False\n\n\ndef create_config_file():\n    \"\"\"Ensure config.toml exists, create with default values if needed.\"\"\"\n    config_file = Path(\"config.toml\")\n    if config_file.exists():\n        logger.info(f\"⏩ Configuration file already exists: {config_file}\")\n        return True\n\n    logger.info(\"Creating config.toml using configuration system defaults\")\n    # The config manager will create it with defaults if it doesn't exist\n    from imagesorcery_mcp.config import get_config_manager\n    get_config_manager()\n    print(f\"✅ Configuration file created with default values: {config_file}\")\n    return True\n\n\ndef create_user_id_file():\n    \"\"\"Ensure .user_id file exists in the project root, create a new UUID if needed.\"\"\"\n    user_id_file = Path(\".user_id\")\n    if user_id_file.exists():\n        logger.info(f\"⏩ .user_id file already exists: {user_id_file}\")\n        return True\n\n    logger.info(\"Creating .user_id file for telemetry...\")\n    try:\n        user_id = str(uuid.uuid4())\n        user_id_file.write_text(user_id)\n        logger.info(f\"Generated new .user_id file with user_id: {user_id_file}\")\n        print(f\"✅ .user_id file created: {user_id_file}\")\n        return True\n    except Exception as e:\n        logger.error(f\"Failed to create .user_id file: {e}\")\n        print(f\"❌ Failed to create .user_id file: {e}\")\n        return False\n\n\ndef run_post_install():\n    \"\"\"Run all post-installation tasks.\"\"\"\n    logger.info(f\"Running post-installation tasks from {Path(__file__).resolve()}...\")\n\n    # Get API keys from environment variables (for Step 4)\n    amplitude_api_key = os.environ.get(\"IMAGESORCERY_AMPLITUDE_API_KEY\", \"\")\n    posthog_api_key = os.environ.get(\"IMAGESORCERY_POSTHOG_API_KEY\", \"\")\n    \n    logger.debug(f\"Amplitude API key from environment: {'*' * 8 if amplitude_api_key else 'Not set'}\")\n    logger.debug(f\"PostHog API key from environment: {'*' * 8 if posthog_api_key else 'Not set'}\")\n\n    # Create configuration file\n    logger.info(\"Creating configuration file...\")\n    config_created = create_config_file()\n    if not config_created:\n        logger.error(\"Failed to create configuration file\")\n        return False\n\n    # Create user ID file for telemetry\n    logger.info(\"Creating user ID file...\")\n    user_id_created = create_user_id_file()\n    if not user_id_created:\n        logger.error(\"Failed to create user ID file\")\n        # Do not return False here, as telemetry is not critical for core functionality\n        # and other post-install tasks should still proceed.\n\n    # Create models directory\n    models_dir = Path(\"models\").resolve()\n    os.makedirs(models_dir, exist_ok=True)\n    logger.info(f\"Created models directory: {models_dir}\")\n\n    # Create model descriptions file\n    logger.info(\"Creating model descriptions file...\")\n    descriptions_file = create_model_descriptions()\n    if descriptions_file:\n        logger.info(f\"Model descriptions file created at: {descriptions_file}\")\n    else:\n        logger.error(\"Failed to create model descriptions file\")\n        return False\n\n    # Download default Ultralytics YOLO models\n    default_models = [\n        \"yoloe-11l-seg-pf.pt\",\n        \"yoloe-11s-seg-pf.pt\",\n        \"yoloe-11l-seg.pt\",\n        \"yoloe-11s-seg.pt\"\n    ]\n    \n    logger.info(\"Downloading default Ultralytics YOLO models...\")\n    for model in default_models:\n        logger.info(f\"Downloading {model}...\")\n        success = download_ultralytics_model(model)\n        if not success:\n            logger.error(f\"Failed to download model: {model}\")\n            return False\n    print(\"✅ Ultralytics YOLO models download completed successfully\")\n\n    # Install CLIP package\n    logger.info(\"Installing CLIP package for text prompts...\")\n    clip_installed_successfully = install_clip()\n    if not clip_installed_successfully:\n        logger.warning(\"CLIP Python package installation failed. The 'find' tool's text prompt functionality might be limited or unavailable.\")\n        print(\"⚠️ WARNING: CLIP Python package installation failed. Text prompt features of the 'find' tool may not work.\")\n        print(\"   Models for CLIP will still be downloaded. If you need this functionality, please try installing the CLIP package manually:\")\n        print(\"   pip install git+https://github.com/ultralytics/CLIP.git\")\n        # We continue with the rest of the post-installation, especially downloading CLIP models.\n    \n    # Download CLIP model\n    logger.info(\"Downloading CLIP model for text prompts...\")\n    try:\n        # Download the CLIP model file\n        success = download_clip_model()\n        if not success:\n            logger.error(\"Failed to download CLIP model\")\n            return False\n    except Exception as e:\n        logger.error(f\"Error downloading CLIP model: {str(e)}\")\n        return False\n    print(\"✅ CLIP model download completed successfully\")\n    \n    logger.info(\"Post-installation tasks completed successfully!\")\n    print(\"✅ Post-installation tasks completed successfully!\")\n    return True\n\n\ndef main():\n    \"\"\"Main entry point for the post_install script.\"\"\"\n    # Load .env file if available\n    if DOTENV_AVAILABLE:\n        env_file = Path(\".env\")\n        if env_file.exists():\n            load_dotenv()\n            logger.info(f\"Loaded environment variables from {env_file}\")\n        else:\n            logger.info(\".env file not found, skipping dotenv loading\")\n    else:\n        logger.info(\"python-dotenv not available, skipping .env file loading\")\n    \n    logger.info(f\"Starting post-installation process from {Path(__file__).resolve()}\")\n    success = run_post_install()\n    if not success:\n        logger.error(\"Post-installation process failed\")\n        sys.exit(1)\n    logger.info(\"Post-installation process completed\")\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "src/imagesorcery_mcp/server.py",
    "content": "import argparse\nimport os\nimport sys\nfrom pathlib import Path\n\nfrom fastmcp import FastMCP\nfrom fastmcp.server.middleware.error_handling import ErrorHandlingMiddleware\n\nfrom imagesorcery_mcp.logging_config import logger\n\n# Change to project root directory\nproject_root = Path(__file__).parent.parent.parent\nos.chdir(project_root)\nlogger.info(f\"Changed current working directory to: {project_root}\")\n\n# Load environment variables from .env if python-dotenv is available (so handlers see keys on import)\ntry:\n    from dotenv import load_dotenv  # type: ignore\n    env_file = project_root / \".env\"\n    if env_file.exists():\n        load_dotenv(env_file)\n        logger.info(f\"Loaded environment variables from: {env_file}\")\n    else:\n        logger.debug(\".env file not found, skipping dotenv loading\")\nexcept Exception:\n    logger.debug(\"python-dotenv not available, skipping .env loading\")\n\nfrom imagesorcery_mcp.middlewares.path_access import PathAccessMiddleware  # noqa: E402\nfrom imagesorcery_mcp.middlewares.telemetry import TelemetryMiddleware  # noqa: E402\nfrom imagesorcery_mcp.middlewares.validation import (  # noqa: E402\n    ImprovedValidationMiddleware,  # noqa: E402\n)\nfrom imagesorcery_mcp.prompts import remove_background  # noqa: E402\nfrom imagesorcery_mcp.resources import models  # noqa: E402\nfrom imagesorcery_mcp.tools import (  # noqa: E402\n    blur,\n    change_color,\n    config,\n    crop,\n    detect,\n    draw_arrows,\n    draw_circle,\n    draw_lines,\n    draw_rectangle,\n    draw_text,\n    fill,\n    find,\n    metainfo,\n    ocr,\n    overlay,\n    resize,\n    rotate,\n)\n\n# Create a module-level mcp instance for backward compatibility with tests\nmcp = FastMCP(\n    name=\"imagesorcery-mcp\",\n    instructions=(\n        \"An MCP server providing tools for image processing operations. \"\n        \"Input images must be specified with full paths.\"\n    )\n)\n\nerror_middleware = ErrorHandlingMiddleware(\n    logger=logger,\n    include_traceback=True,\n    transform_errors=True,\n)\nmcp.add_middleware(error_middleware)\n\ntelemetry_middleware = TelemetryMiddleware(logger=logger)\nmcp.add_middleware(telemetry_middleware)\n\npath_access_middleware = PathAccessMiddleware(logger=logger)\nmcp.add_middleware(path_access_middleware)\n\nvalidation_middleware = ImprovedValidationMiddleware(logger=logger)\nmcp.add_middleware(validation_middleware)\n\n# Register tools with the module-level mcp instance\nblur.register_tool(mcp)\nchange_color.register_tool(mcp)\nconfig.register_tool(mcp)\ncrop.register_tool(mcp)\ndetect.register_tool(mcp)\ndraw_arrows.register_tool(mcp)\ndraw_circle.register_tool(mcp)\ndraw_lines.register_tool(mcp)\ndraw_rectangle.register_tool(mcp)\ndraw_text.register_tool(mcp)\nfill.register_tool(mcp)\nfind.register_tool(mcp)\nmetainfo.register_tool(mcp)\nocr.register_tool(mcp)\noverlay.register_tool(mcp)\nresize.register_tool(mcp)\nrotate.register_tool(mcp)\n\n# Register resources\nmodels.register_resource(mcp)\n\n# Register prompts\nremove_background.register_prompt(mcp)\n\ndef parse_arguments():\n    \"\"\"Parse command line arguments.\"\"\"\n    parser = argparse.ArgumentParser(description=\"ImageSorcery MCP Server\")\n    parser.add_argument(\n        \"--post-install\", \n        action=\"store_true\", \n        help=\"Run post-installation tasks and exit\"\n    )\n    parser.add_argument(\n        \"--transport\",\n        type=str,\n        default=\"stdio\",\n        choices=[\"stdio\", \"streamable-http\", \"sse\"],\n        help=\"Transport protocol to use (default: stdio)\"\n    )\n    parser.add_argument(\n        \"--host\",\n        type=str,\n        default=\"127.0.0.1\",\n        help=\"Host to bind to when using HTTP-based transports (default: 127.0.0.1)\"\n    )\n    parser.add_argument(\n        \"--port\",\n        type=int,\n        default=8000,\n        help=\"Port to bind to when using HTTP-based transports (default: 8000)\"\n    )\n    parser.add_argument(\n        \"--path\",\n        type=str,\n        default=\"/mcp\",\n        help=\"Path for the MCP endpoint when using HTTP-based transports (default: /mcp)\"\n    )\n    return parser.parse_args()\n\ndef main():\n    \"\"\"Main entry point for the server.\"\"\"\n    args = parse_arguments()\n    \n    logger.info(\"Starting 🪄 ImageSorcery MCP server setup\")\n    \n    # Get version from package metadata\n    try:\n        from importlib.metadata import version\n        package_version = version(\"imagesorcery-mcp\")\n        print(f\"ImageSorcery MCP Version: {package_version}\")\n    except Exception as e:\n        logger.error(f\"Could not read version from package metadata: {e}\")\n        print(\"ImageSorcery MCP Version: unknown\")\n\n    \n    # If --post-install flag is provided, run post-installation tasks and exit\n    if args.post_install:\n        logger.info(\"Post-installation flag detected, running post-installation tasks\")\n        try:\n            from imagesorcery_mcp.scripts.post_install import run_post_install\n            success = run_post_install()\n            if not success:\n                logger.error(\"Post-installation tasks failed\")\n                sys.exit(1)\n            logger.info(\"Post-installation tasks completed successfully\")\n            sys.exit(0)\n        except Exception as e:\n            logger.error(f\"Error during post-installation: {str(e)}\")\n            sys.exit(1)\n    \n    # For actual server execution, we'll use the global mcp instance\n    logger.info(f\"Starting MCP server with transport: {args.transport}\")\n    \n    fastmcp_log_level = os.getenv(\"FASTMCP_LOG_LEVEL\", \"DEBUG\")\n\n    # Configure transport with appropriate parameters\n    if args.transport in [\"streamable-http\", \"sse\"]:\n        mcp.run(\n            transport=args.transport,\n            host=args.host,\n            port=args.port,\n            path=args.path,\n            log_level=fastmcp_log_level\n        )\n    else:\n        # Use default stdio transport\n        mcp.run(log_level=fastmcp_log_level)\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "src/imagesorcery_mcp/telemetry_amplitude.py",
    "content": "import logging\nimport os\nfrom typing import Any, Dict\n\nfrom amplitude import Amplitude, BaseEvent\n\nfrom imagesorcery_mcp.telemetry_keys import AMPLITUDE_API_KEY\n\n\nclass AmplitudeHandler:\n    \"\"\"Handles sending telemetry events to Amplitude.\"\"\"\n\n    def __init__(self, logger: logging.Logger | None = None):\n        self.logger = logger or logging.getLogger(\"imagesorcery.telemetry.amplitude\")\n        self.logger.debug(\"Initializing Amplitude handler\")\n        \n        api_key = self._get_api_key()\n        \n        if not api_key:\n            self.amplitude = None\n            self.logger.warning(\"Amplitude API key is not set. Amplitude telemetry will be disabled.\")\n            self.logger.debug(\"Amplitude telemetry disabled due to missing API key\")\n        else:\n            self.amplitude = Amplitude(api_key)\n            self.logger.info(\"Amplitude handler initialized.\")\n            self.logger.debug(f\"Amplitude handler enabled with API key: {api_key}\")\n\n    def _get_api_key(self) -> str:\n        \"\"\"Get Amplitude API key.\n\n        Priority:\n        1. Environment variable IMAGESORCERY_AMPLITUDE_API_KEY\n        2. Value from src/imagesorcery_mcp/telemetry_keys.py (AMPLITUDE_API_KEY)\n        \"\"\"\n        return os.environ.get('IMAGESORCERY_AMPLITUDE_API_KEY', AMPLITUDE_API_KEY)\n\n    def track_event(self, event_data: Dict[str, Any]):\n        \"\"\"\n        Tracks an event using Amplitude.\n\n        Args:\n            event_data: A dictionary containing event properties.\n                        Expected keys: 'user_id', 'action_type', 'identifier', 'status', etc.\n        \"\"\"\n        if not self.amplitude:\n            self.logger.debug(\"Amplitude telemetry disabled, skipping event tracking\")\n            return\n            \n        # Skip telemetry if DISABLE_TELEMETRY environment variable is set\n        if os.environ.get('DISABLE_TELEMETRY', '').lower() in ('true', '1', 'yes'):\n            self.logger.debug(\"Amplitude telemetry disabled via environment variable\")\n            return\n\n        try:\n            user_id = event_data.get(\"user_id\", \"anonymous\")\n            event_type = f\"mcp_{event_data.get('action_type', 'unknown_action')}\"\n            \n            self.logger.debug(f\"Preparing to track Amplitude event: {event_type} for user {user_id}\")\n            self.logger.debug(f\"Event data: {event_data}\")\n\n            event = BaseEvent(event_type=event_type, user_id=user_id, event_properties=event_data)\n\n            self.amplitude.track(event)\n            self.logger.debug(f\"Successfully tracked Amplitude event: {event_type} for user {user_id}\")\n\n        except Exception as e:\n            self.logger.error(f\"Failed to send event to Amplitude: {e}\", exc_info=True)\n            self.logger.debug(f\"Event data that failed: {event_data}\")\n\n\n# Global instance to be used by other modules\namplitude_handler = AmplitudeHandler()\n"
  },
  {
    "path": "src/imagesorcery_mcp/telemetry_keys.py",
    "content": "# Auto-generated telemetry keys module.\n# This file is intended to be updated by build scripts (populate_telemetry_keys.py)\n# and cleared by clear_telemetry_keys.py. Keep values as empty strings in the repo.\n#\n# WARNING: Do NOT commit real production keys to the repository.\n\nAMPLITUDE_API_KEY = \"\"\nPOSTHOG_API_KEY = \"\"\n"
  },
  {
    "path": "src/imagesorcery_mcp/telemetry_posthog.py",
    "content": "import logging\nimport os\nfrom typing import Any, Dict\n\nfrom posthog import Posthog\n\nfrom imagesorcery_mcp.telemetry_keys import POSTHOG_API_KEY\n\nPOSTHOG_HOST = \"https://us.i.posthog.com\"\n\n\nclass PostHogHandler:\n    \"\"\"Handles sending telemetry events to PostHog.\"\"\"\n\n    def __init__(self, logger: logging.Logger | None = None):\n        self.logger = logger or logging.getLogger(\"imagesorcery.telemetry.posthog\")\n        self.logger.debug(\"Initializing PostHog handler\")\n        \n        api_key = self._get_api_key()\n        \n        if not api_key:\n            self.enabled = False\n            self.logger.warning(\"PostHog API key is not set. PostHog telemetry will be disabled.\")\n            self.logger.debug(\"PostHog telemetry disabled due to missing API key\")\n        else:\n            self.enabled = True\n            self.posthog_client = Posthog(api_key, host=POSTHOG_HOST)\n            self.logger.info(\"PostHog handler initialized.\")\n            self.logger.debug(f\"PostHog handler enabled with API key: {api_key}\")\n\n    def _get_api_key(self) -> str:\n        \"\"\"Get PostHog API key.\n\n        Priority:\n        1. Environment variable IMAGESORCERY_POSTHOG_API_KEY\n        2. Value from src/imagesorcery_mcp/telemetry_keys.py (POSTHOG_API_KEY)\n        \"\"\"\n        return os.environ.get('IMAGESORCERY_POSTHOG_API_KEY', POSTHOG_API_KEY)\n\n    def track_event(self, event_data: Dict[str, Any]):\n        \"\"\"\n        Tracks an event using PostHog.\n\n        Args:\n            event_data: A dictionary containing event properties.\n                        Expected keys: 'user_id', 'action_type', 'identifier', 'status', etc.\n        \"\"\"\n        if not self.enabled:\n            self.logger.debug(\"PostHog telemetry disabled, skipping event tracking\")\n            return\n            \n        # Skip telemetry if DISABLE_TELEMETRY environment variable is set\n        if os.environ.get('DISABLE_TELEMETRY', '').lower() in ('true', '1', 'yes'):\n            self.logger.debug(\"Posthog telemetry disabled via environment variable\")\n            return\n\n        try:\n            user_id = event_data.get(\"user_id\", \"anonymous\")\n            event_type = f\"mcp_{event_data.get('action_type', 'unknown_action')}\"\n            \n            self.logger.debug(f\"Preparing to track PostHog event: {event_type} for user {user_id}\")\n            self.logger.debug(f\"Event data: {event_data}\")\n\n            self.posthog_client.capture(event_type, distinct_id=user_id, properties=event_data)\n            self.logger.debug(f\"Successfully tracked PostHog event: {event_type} for user {user_id}\")\n\n        except Exception as e:\n            self.logger.error(f\"Failed to send event to PostHog: {e}\", exc_info=True)\n            self.logger.debug(f\"Event data that failed: {event_data}\")\n\n\nposthog_handler = PostHogHandler()\n"
  },
  {
    "path": "src/imagesorcery_mcp/tools/README.md",
    "content": "# 🪄 ImageSorcery MCP Server Tools Documentation\n\nThis document provides detailed information about each tool available in the 🪄 ImageSorcery MCP Server, including their arguments, return values, and examples of how to call them using a Claude client.\n\n## Rules\n\nThese rules apply to all contributors: humans and AI.\n\n- Register tools by defining a `register_tool` function in each tool's module. This function should accept a `FastMCP` instance and use the `@mcp.tool()` decorator to register the tool function with the server. See `src/imagesorcery_mcp/server.py` for how tools are imported and registered, and individual tool files like `src/imagesorcery_mcp/tools/crop.py` for examples of the `register_tool` function implementation.\n- All tools should use Bounding Box format for image coordinates, e.g. `[x1, y1, x2, y2]` where `(x1, y1)` is the top-left corner and `(x2, y2)` is the bottom-right corner.\n- All file paths specified in tool arguments (e.g., `input_path`, `output_path`) must be **full paths**, not relative paths. For example, use `/home/user/images/my_image.jpg` instead of `my_image.jpg`.\n- When adding new tools, ensure they are listed in alphabetical order in READMEs and in the server registration.\n\n\n## Available Tools\n\n### `blur`\n\nBlurs specified rectangular or polygonal areas of an image using OpenCV. This tool allows blurring multiple areas of an image with customizable blur strength. Each area can be a rectangle defined by a bounding box with coordinates `[x1, y1, x2, y2]` or a polygon defined by a list of points (in the same format as returned by `detect` or `find`). The `blur_strength` parameter controls the intensity of the blur effect. Higher values result in stronger blur. It must be an odd number (default is 15). If `invert_areas` is True, the tool will blur everything EXCEPT the specified areas.\n\n- **Required arguments:**\n  - `input_path` (string): Full path to the input image\n  - `areas` (array): List of areas to blur. Each item is a dictionary that must contain either:\n    - A rectangle: `x1`, `y1`, `x2`, `y2` (integers).\n    - A polygon: `polygon` (a list of points, e.g., `[[x1, y1], [x2, y2], ...]`).\n    - Optionally, each dictionary can also contain `blur_strength` (integer, default is 15).\n- **Optional arguments:**\n  - `invert_areas` (boolean): If True, blurs everything EXCEPT the specified areas. Useful for background blurring. Default is False.\n  - `output_path` (string): Full path to save the output image. If not provided, will use input filename with '_blurred' suffix.\n- **Returns:** string (path to the image with blurred areas)\n\n**Example Claude Request:**\n\n```\nBlur the rectangular area from (150, 100) to (250, 200) and a triangular area in my image 'test_image.png' and save it as 'output.png'\n```\n\n**Example Tool Call (JSON):**\n\n```json\n{\n  \"name\": \"blur\",\n  \"arguments\": {\n    \"input_path\": \"/home/user/images/test_image.png\",\n    \"areas\": [\n      {\n        \"x1\": 150,\n        \"y1\": 100,\n        \"x2\": 250,\n        \"y2\": 200,\n        \"blur_strength\": 21\n      },\n      {\n        \"polygon\": [[300, 50], [350, 50], [325, 150]],\n        \"blur_strength\": 31\n      }\n    ],\n    \"output_path\": \"/home/user/images/output.png\"\n  }\n}\n```\n\n**Example Claude Request (Background Blurring):**\n\n```\nBlur the background of 'my_image.png' by blurring everything outside the rectangle (100, 100) to (300, 300) with a blur strength of 25, and save it as 'object_focused.png'\n```\n\n**Example Tool Call (JSON):**\n\n```json\n{\n  \"name\": \"blur\",\n  \"arguments\": {\n    \"input_path\": \"/home/user/images/my_image.png\",\n    \"areas\": [\n      {\n        \"x1\": 100,\n        \"y1\": 100,\n        \"x2\": 300,\n        \"y2\": 300,\n        \"blur_strength\": 25\n      }\n    ],\n    \"invert_areas\": true,\n    \"output_path\": \"/home/user/images/object_focused.png\"\n  }\n}\n```\n\n**Example Response (JSON):**\n\n```json\n{\n  \"result\": \"/home/user/images/output.png\"\n}\n```\n\n### `change_color`\n\nChanges the color palette of an image. This tool applies a predefined color transformation to an image. Currently supported palettes are 'grayscale' and 'sepia'.\n\n- **Required arguments:**\n  - `input_path` (string): Full path to the input image\n  - `palette` (string): The color palette to apply. Currently supports 'grayscale' and 'sepia'.\n- **Optional arguments:**\n  - `output_path` (string): Full path to save the output image. If not provided, will use input filename with a suffix based on the palette (e.g., '_grayscale').\n- **Returns:** string (path to the image with the new color palette)\n\n**Example Claude Request:**\n\n```\nConvert my image 'test_image.png' to sepia and save it as 'output.png'\n```\n\n**Example Tool Call (JSON):**\n\n```json\n{\n  \"name\": \"change_color\",\n  \"arguments\": {\n    \"input_path\": \"/home/user/images/test_image.png\",\n    \"palette\": \"sepia\",\n    \"output_path\": \"/home/user/images/output.png\"\n  }\n}\n```\n\n**Example Response (JSON):**\n\n```json\n{\n  \"result\": \"/home/user/images/output.png\"\n}\n```\n\n### `config`\n\nView or update ImageSorcery MCP configuration settings. This tool allows you to view current configuration values, update them for the current session or persistently, and reset runtime overrides. Configuration values control default parameters for other tools like detection confidence thresholds, blur strength, drawing colors, etc.\n\n- **Required arguments:**\n  - `action` (string): Action to perform. Must be one of:\n    - `\"get\"`: View configuration values\n    - `\"set\"`: Update configuration values\n    - `\"reset\"`: Reset runtime configuration overrides\n- **Optional arguments:**\n  - `key` (string): Configuration key to get/set using dot notation (e.g., \"detection.confidence_threshold\", \"blur.strength\"). Leave empty to get/set entire config.\n  - `value` (string|number|boolean): Value to set (only used with action=\"set\")\n  - `persist` (boolean): Whether to persist changes to config file (only used with action=\"set\"). Default is False.\n- **Returns:** Dictionary containing the requested configuration data or update result\n\n**Available Configuration Keys:**\n- `detection.confidence_threshold`: Default confidence threshold for object detection (0.0-1.0)\n- `detection.default_model`: Default model for detection tool\n- `find.confidence_threshold`: Default confidence threshold for object finding (0.0-1.0)\n- `find.default_model`: Default model for find tool\n- `blur.strength`: Default blur strength (must be odd number)\n- `text.font_scale`: Default font scale for text drawing\n- `drawing.color`: Default color in BGR format [Blue, Green, Red]\n- `drawing.thickness`: Default line thickness\n- `ocr.language`: Default language code for OCR\n- `resize.interpolation`: Default interpolation method\n\n**Example Claude Requests:**\n\n```\nShow me the current configuration\n```\n\n```\nWhat is the current detection confidence threshold?\n```\n\n```\nSet the default blur strength to 21\n```\n\n```\nSet the detection confidence to 0.8 and save it to the config file\n```\n\n```\nReset all configuration overrides\n```\n\n**Example Tool Calls (JSON):**\n\nGet all configuration:\n```json\n{\n  \"name\": \"config\",\n  \"arguments\": {\n    \"action\": \"get\"\n  }\n}\n```\n\nGet specific configuration value:\n```json\n{\n  \"name\": \"config\",\n  \"arguments\": {\n    \"action\": \"get\",\n    \"key\": \"detection.confidence_threshold\"\n  }\n}\n```\n\nSet configuration value (runtime only):\n```json\n{\n  \"name\": \"config\",\n  \"arguments\": {\n    \"action\": \"set\",\n    \"key\": \"blur.strength\",\n    \"value\": 21,\n    \"persist\": false\n  }\n}\n```\n\nSet and persist configuration value:\n```json\n{\n  \"name\": \"config\",\n  \"arguments\": {\n    \"action\": \"set\",\n    \"key\": \"detection.confidence_threshold\",\n    \"value\": 0.8,\n    \"persist\": true\n  }\n}\n```\n\nReset runtime overrides:\n```json\n{\n  \"name\": \"config\",\n  \"arguments\": {\n    \"action\": \"reset\"\n  }\n}\n```\n\n### `crop`\n\nCrops an image using OpenCV's NumPy slicing approach.\n\n- **Required arguments:**\n  - `input_path` (string): Full path to the input image\n  - `x1` (integer): X-coordinate of the top-left corner\n  - `y1` (integer): Y-coordinate of the top-left corner\n  - `x2` (integer): X-coordinate of the bottom-right corner\n  - `y2` (integer): Y-coordinate of the bottom-right corner\n- **Optional arguments:**\n  - `output_path` (string): Full path to save the output image. If not provided, will use input filename with '_cropped' suffix.\n- **Returns:** string (path to the cropped image)\n\n**Example Claude Request:**\n\n```\nCrop my image 'input.png' using bounding box [10, 10, 200, 200] and save it as 'cropped.png'\n```\n\n**Example Tool Call (JSON):**\n\n```json\n{\n  \"name\": \"crop\",\n  \"arguments\": {\n    \"input_path\": \"/home/user/images/input.png\",\n    \"x1\": 10,\n    \"y1\": 10,\n    \"x2\": 200,\n    \"y2\": 200,\n    \"output_path\": \"/home/user/images/cropped.png\"\n  }\n}\n```\n\n**Example Response (JSON):**\n\n```json\n{\n  \"result\": \"/home/user/images/cropped.png\"\n}\n```\n\n### `detect`\n\nDetects objects in an image using models from Ultralytics. This tool requires pre-downloaded models. Use the `download-yolo-models` command to download models before using this tool. If objects aren't common, consider using a specialized model. This tool can optionally return segmentation masks (as PNG files) or polygons if a segmentation model (e.g., one ending in '-seg.pt') is used.\n\n- **Required arguments:**\n  - `input_path` (string): Full path to the input image\n- **Optional arguments:**\n  - `confidence` (float): Confidence threshold for detection (0.0 to 1.0). Default is 0.75\n  - `model_name` (string): Model name to use for detection (e.g., 'yoloe-11s-seg.pt', 'yolov8m.pt'). Default is 'yoloe-11l-seg-pf.pt'\n  - `return_geometry` (boolean): If True, returns segmentation masks or polygons for detected objects. Default is False.\n  - `geometry_format` (string): Format for returned geometry: 'mask' or 'polygon'. Default is 'mask'. When 'mask' is selected, a PNG file is created for each mask and its path is returned.\n- **Returns:** dictionary containing:\n  - `image_path`: Path to the input image\n  - `detections`: List of detected objects, each with:\n    - `class`: Class name of the detected object\n    - `confidence`: Confidence score (0.0 to 1.0)\n    - `bbox`: Bounding box coordinates [x1, y1, x2, y2]\n    - `mask_path` (optional): Path to the PNG file for the object's mask. Included if `return_geometry` is True and `geometry_format` is 'mask'.\n    - `polygon` (optional): A list of points `[x, y]` describing the object's contour. Included if `return_geometry` is True and `geometry_format` is 'polygon'.\n\n**Example Claude Request:**\n\n```\nDetect objects in my image 'photo.jpg' with a confidence threshold of 0.4\n```\n\n**Example Tool Call (JSON):**\n\n```json\n{\n  \"tool_code\": \"imagesorcery-mcp\",\n  \"name\": \"detect\",\n  \"arguments\": {\n    \"input_path\": \"/home/user/images/photo.jpg\",\n    \"confidence\": 0.4,\n    \"return_geometry\": true,\n    \"geometry_format\": \"polygon\"\n  }\n}\n```\n\n**Example Response (JSON):**\n\n```json\n{\n  \"result\": {\n    \"image_path\": \"/home/user/images/photo.jpg\",\n    \"detections\": [\n      {\n        \"class\": \"person\",\n        \"confidence\": 0.92,\n        \"bbox\": [10.5, 20.3, 100.2, 200.1],\n        \"mask_path\": \"/home/user/images/photo_mask_0.png\"\n      },\n      {\n        \"class\": \"car\",\n        \"confidence\": 0.85,\n        \"bbox\": [150.2, 30.5, 250.1, 120.7],\n        \"mask_path\": \"/home/user/images/photo_mask_1.png\"\n      }\n    ]\n  }\n}\n```\n\n### `draw_arrows`\n\nDraws arrows on an image using OpenCV. This tool allows adding multiple arrows to an image with customizable start and end points, color, thickness, and tip length.\n\n- **Required arguments:**\n  - `input_path` (string): Full path to the input image\n  - `arrows` (array): List of arrow items to draw. Each item should have:\n    - `x1` (integer): X-coordinate of the arrow's start point\n    - `y1` (integer): Y-coordinate of the arrow's start point\n    - `x2` (integer): X-coordinate of the arrow's end point\n    - `y2` (integer): Y-coordinate of the arrow's end point\n    - `color` (array, optional): Color in BGR format [B,G,R]. Default is [0,0,0] (black)\n    - `thickness` (integer, optional): Line thickness. Default is 1\n    - `tip_length` (float, optional): Length of the arrow tip relative to the arrow length. Default is 0.1\n- **Optional arguments:**\n  - `output_path` (string): Full path to save the output image. If not provided, will use input filename with '_with_arrows' suffix\n\n- **Returns:** string (path to the image with drawn arrows)\n\n**Example Claude Request:**\n\n```\nDraw a red arrow from (50,50) to (150,100) and a blue arrow from (200,150) to (300,250) with a tip length of 0.15 on my image 'photo.jpg'\n```\n\n**Example Tool Call (JSON):**\n\n```json\n{\n  \"name\": \"draw_arrows\",\n  \"arguments\": {\n    \"input_path\": \"/home/user/images/photo.jpg\",\n    \"arrows\": [\n      {\n        \"x1\": 50,\n        \"y1\": 50,\n        \"x2\": 150,\n        \"y2\": 100,\n        \"color\": [0, 0, 255],\n        \"thickness\": 2\n      },\n      {\n        \"x1\": 200,\n        \"y1\": 150,\n        \"x2\": 300,\n        \"y2\": 250,\n        \"color\": [255, 0, 0],\n        \"thickness\": 3,\n        \"tip_length\": 0.15\n      }\n    ],\n    \"output_path\": \"/home/user/images/photo_with_arrows.jpg\"\n  }\n}\n```\n\n**Example Response (JSON):**\n\n```json\n{\n  \"result\": \"/home/user/images/photo_with_arrows.jpg\"\n}\n```\n\n### `draw_circles`\n\nDraws circles on an image using OpenCV. This tool allows adding multiple circles to an image with customizable center, radius, color, thickness, and fill option. Each circle is defined by its center coordinates (center_x, center_y) and radius.\n\n- **Required arguments:**\n  - `input_path` (string): Full path to the input image\n  - `circles` (array): List of circle items to draw. Each item should have:\n    - `center_x` (integer): X-coordinate of the circle's center\n    - `center_y` (integer): Y-coordinate of the circle's center\n    - `radius` (integer): Radius of the circle\n    - `color` (array, optional): Color in BGR format [B,G,R]. Default is [0,0,0] (black)\n    - `thickness` (integer, optional): Line thickness. Default is 1. Use -1 for a filled circle.\n    - `filled` (boolean, optional): Whether to fill the circle. Default is false. If true, thickness is set to -1.\n- **Optional arguments:**\n  - `output_path` (string): Full path to save the output image. If not provided, will use input filename with '_with_circles' suffix\n\n- **Returns:** string (path to the image with drawn circles)\n\n**Example Claude Request:**\n\n```\nDraw a red circle with center (100,100) and radius 50, and a filled blue circle with center (250,200) and radius 30 on my image 'photo.jpg'\n```\n\n**Example Tool Call (JSON):**\n\n```json\n{\n  \"name\": \"draw_circles\",\n  \"arguments\": {\n    \"input_path\": \"/home/user/images/photo.jpg\",\n    \"circles\": [\n      {\n        \"center_x\": 100,\n        \"center_y\": 100,\n        \"radius\": 50,\n        \"color\": [0, 0, 255],\n        \"thickness\": 2\n      },\n      {\n        \"center_x\": 250,\n        \"center_y\": 200,\n        \"radius\": 30,\n        \"color\": [255, 0, 0],\n        \"filled\": true\n      }\n    ],\n    \"output_path\": \"/home/user/images/photo_with_circles.jpg\"\n  }\n}\n```\n\n**Example Response (JSON):**\n\n```json\n{\n  \"result\": \"/home/user/images/photo_with_circles.jpg\"\n}\n```\n\n### `draw_lines`\n\nDraws lines on an image using OpenCV. This tool allows adding multiple lines to an image with customizable start and end points, color, and thickness.\n\n- **Required arguments:**\n  - `input_path` (string): Full path to the input image\n  - `lines` (array): List of line items to draw. Each item should have:\n    - `x1` (integer): X-coordinate of the line's start point\n    - `y1` (integer): Y-coordinate of the line's start point\n    - `x2` (integer): X-coordinate of the line's end point\n    - `y2` (integer): Y-coordinate of the line's end point\n    - `color` (array, optional): Color in BGR format [B,G,R]. Default is [0,0,0] (black)\n    - `thickness` (integer, optional): Line thickness. Default is 1\n- **Optional arguments:**\n  - `output_path` (string): Full path to save the output image. If not provided, will use input filename with '_with_lines' suffix\n\n- **Returns:** string (path to the image with drawn lines)\n\n**Example Claude Request:**\n\n```\nDraw a red line from (50,50) to (150,100) and a blue line from (200,150) to (300,250) on my image 'photo.jpg'\n```\n\n**Example Tool Call (JSON):**\n\n```json\n{\n  \"name\": \"draw_lines\",\n  \"arguments\": {\n    \"input_path\": \"/home/user/images/photo.jpg\",\n    \"lines\": [\n      {\n        \"x1\": 50,\n        \"y1\": 50,\n        \"x2\": 150,\n        \"y2\": 100,\n        \"color\": [0, 0, 255],\n        \"thickness\": 2\n      },\n      {\n        \"x1\": 200,\n        \"y1\": 150,\n        \"x2\": 300,\n        \"y2\": 250,\n        \"color\": [255, 0, 0],\n        \"thickness\": 3\n      }\n    ],\n    \"output_path\": \"/home/user/images/photo_with_lines.jpg\"\n  }\n}\n```\n\n**Example Response (JSON):**\n\n```json\n{\n  \"result\": \"/home/user/images/photo_with_lines.jpg\"\n}\n```\n\n### `draw_rectangles`\n\nDraws rectangles on an image using OpenCV. This tool allows adding multiple rectangles to an image with customizable position, color, thickness, and fill option. Each rectangle is defined by two points: (x1, y1) for the top-left corner and (x2, y2) for the bottom-right corner.\n\n- **Required arguments:**\n  - `input_path` (string): Full path to the input image\n  - `rectangles` (array): List of rectangle items to draw. Each item should have:\n    - `x1` (integer): X-coordinate of the top-left corner\n    - `y1` (integer): Y-coordinate of the top-left corner\n    - `x2` (integer): X-coordinate of the bottom-right corner\n    - `y2` (integer): Y-coordinate of the bottom-right corner\n    - `color` (array, optional): Color in BGR format [B,G,R]. Default is [0,0,0] (black)\n    - `thickness` (integer, optional): Line thickness. Default is 1\n    - `filled` (boolean, optional): Whether to fill the rectangle. Default is false\n- **Optional arguments:**\n  - `output_path` (string): Full path to save the output image. If not provided, will use input filename with '_with_rectangles' suffix\n\n- **Returns:** string (path to the image with drawn rectangles)\n\n**Example Claude Request:**\n\n```\nDraw a red rectangle from (50,50) to (150,100) and a filled blue rectangle from (200,150) to (300,250) on my image 'photo.jpg'\n```\n\n**Example Tool Call (JSON):**\n\n```json\n{\n  \"name\": \"draw_rectangles\",\n  \"arguments\": {\n    \"input_path\": \"/home/user/images/photo.jpg\",\n    \"rectangles\": [\n      {\n        \"x1\": 50,\n        \"y1\": 50,\n        \"x2\": 150,\n        \"y2\": 100,\n        \"color\": [0, 0, 255],\n        \"thickness\": 2\n      },\n      {\n        \"x1\": 200,\n        \"y1\": 150,\n        \"x2\": 300,\n        \"y2\": 250,\n        \"color\": [255, 0, 0],\n        \"thickness\": 3,\n        \"filled\": true\n      }\n    ],\n    \"output_path\": \"/home/user/images/photo_with_rectangles.jpg\"\n  }\n}\n```\n\n**Example Response (JSON):**\n\n```json\n{\n  \"result\": \"/home/user/images/photo_with_rectangles.jpg\"\n}\n```\n\n### `draw_texts`\n\nDraws text on an image using OpenCV. This tool allows adding multiple text elements to an image with customizable position, font, size, color, and thickness.\n\n- **Required arguments:**\n  - `input_path` (string): Full path to the input image\n  - `texts` (array): List of text items to draw. Each item should have:\n    - `text` (string): The text to draw\n    - `x` (integer): X-coordinate for the text position\n    - `y` (integer): Y-coordinate for the text position\n    - `font_scale` (float, optional): Scale factor for the font. Default is 1.0\n    - `color` (array, optional): Color in BGR format [B,G,R]. Default is [0,0,0] (black)\n    - `thickness` (integer, optional): Line thickness. Default is 1\n    - `font_face` (string, optional): Font face to use. Default is \"FONT_HERSHEY_SIMPLEX\". Available options: 'FONT_HERSHEY_SIMPLEX', 'FONT_HERSHEY_PLAIN', 'FONT_HERSHEY_DUPLEX', 'FONT_HERSHEY_COMPLEX', 'FONT_HERSHEY_TRIPLEX', 'FONT_HERSHEY_COMPLEX_SMALL', 'FONT_HERSHEY_SCRIPT_SIMPLEX', 'FONT_HERSHEY_SCRIPT_COMPLEX'\n- **Optional arguments:**\n  - `output_path` (string): Full path to save the output image. If not provided, will use input filename with '_with_text' suffix\n\n- **Returns:** string (path to the image with drawn text)\n\n**Example Claude Request:**\n\n```\nAdd text 'Hello World' at position (50,50) and 'Copyright 2023' at the bottom right corner of my image 'photo.jpg'\n```\n\n**Example Tool Call (JSON):**\n\n```json\n{\n  \"name\": \"draw_texts\",\n  \"arguments\": {\n    \"input_path\": \"/home/user/images/photo.jpg\",\n    \"texts\": [\n      {\n        \"text\": \"Hello World\",\n        \"x\": 50,\n        \"y\": 50,\n        \"font_scale\": 1.0,\n        \"color\": [0, 0, 255],\n        \"thickness\": 2\n      },\n      {\n        \"text\": \"Copyright 2023\",\n        \"x\": 100,\n        \"y\": 150,\n        \"font_scale\": 2.0,\n        \"color\": [255, 0, 0],\n        \"thickness\": 3,\n        \"font_face\": \"FONT_HERSHEY_COMPLEX\"\n      }\n    ],\n    \"output_path\": \"/home/user/images/photo_with_text.jpg\"\n  }\n}\n```\n\n**Example Response (JSON):**\n\n```json\n{\n  \"result\": \"/home/user/images/photo_with_text.jpg\"\n}\n```\n\n### `fill`\n\nFills specified rectangular, polygonal, or mask-based areas of an image with a color and opacity, or makes them transparent. This tool allows filling multiple areas of an image with a customizable color and opacity. Each area can be a rectangle, a polygon, or a mask from a PNG file. The `opacity` parameter controls the transparency of the fill (1.0 is fully opaque, 0.0 is fully transparent, default is 0.5). The `color` is in BGR format, e.g., `[255, 0, 0]` for blue (default is `[0,0,0]` black).\n\n**Special behavior**: If `color` is set to `null` (or `None` in Python), the specified area is made fully transparent by setting all channels (BGRA) to 0, effectively creating a black transparent color. This ensures better compatibility with older PNG viewers. The `opacity` parameter is ignored in this case.\n\n- **Required arguments:**\n  - `input_path` (string): Full path to the input image\n  - `areas` (array): List of areas to fill. Each item is a dictionary that must contain one of:\n    - A rectangle: `x1`, `y1`, `x2`, `y2` (integers).\n    - A polygon: `polygon` (a list of points, e.g., `[[x1, y1], [x2, y2], ...]`).\n    - A mask: `mask_path` (string path to a PNG mask file).\n    - Optionally, each dictionary can also contain `color` (list of 3 ints [B,G,R] or `null`, default [0,0,0]) and `opacity` (float 0.0-1.0, default 0.5).\n- **Optional arguments:**\n  - `invert_areas` (boolean): If True, fills everything EXCEPT the specified areas. Useful for background removal. Default is False.\n  - `output_path` (string): Full path to save the output image. If not provided, will use input filename with '_filled' suffix.\n- **Returns:** string (path to the image with filled areas)\n\n**Example Claude Request:**\n\n```\nFill the rectangular area from (150, 100) to (250, 200) with semi-transparent red and erase the area [[10, 10], [50, 10], [50, 50], [10, 50]] in my image 'test_image.png' and save it as 'output.png'\n```\n\n**Example Tool Call (JSON):**\n\n```json\n{\n  \"name\": \"fill\",\n  \"arguments\": {\n    \"input_path\": \"/home/user/images/test_image.png\",\n    \"areas\": [\n      {\n        \"x1\": 150,\n        \"y1\": 100,\n        \"x2\": 250,\n        \"y2\": 200,\n        \"color\": [0, 0, 255],\n        \"opacity\": 0.5\n      },\n      {\n        \"polygon\": [[10, 10], [50, 10], [50, 50], [10, 50]],\n        \"color\": null\n      }\n    ],\n    \"output_path\": \"/home/user/images/output.png\"\n  }\n}\n```\n\n**Example Response (JSON):**\n\n```json\n{\n  \"result\": \"/home/user/images/output.png\"\n}\n```\n\n**Example Claude Request (Background Removal):**\n\n```\nRemove the background from 'my_image.png' by making everything outside the rectangle (100, 100) to (300, 300) transparent, and save it as 'object_only.png'\n```\n\n**Example Tool Call (JSON):**\n\n```json\n{\n  \"name\": \"fill\",\n  \"arguments\": {\n    \"input_path\": \"/home/user/images/my_image.png\",\n    \"areas\": [\n      {\n        \"x1\": 100,\n        \"y1\": 100,\n        \"x2\": 300,\n        \"y2\": 300,\n        \"color\": null\n      }\n    ],\n    \"invert_areas\": true,\n    \"output_path\": \"/home/user/images/object_only.png\"\n  }\n}\n```\n\n### `find`\n\nFinds objects in an image based on a text description. This tool uses open-vocabulary detection models to find objects matching a text description. It requires pre-downloaded YOLOE models that support text prompts (e.g. yoloe-11l-seg.pt). This tool can optionally return segmentation masks (as PNG files) or polygons.\n\n- **Required arguments:**\n  - `input_path` (string): Full path to the input image\n  - `description` (string): Text description of the object to find\n- **Optional arguments:**\n  - `confidence` (float): Confidence threshold for detection (0.0 to 1.0). Default is 0.3\n  - `model_name` (string): Model name to use for finding objects (must support text prompts). Default is 'yoloe-11l-seg.pt'\n  - `return_all_matches` (boolean): If True, returns all matching objects; if False, returns only the best match. Default is False\n  - `return_geometry` (boolean): If True, returns segmentation masks or polygons for found objects. Default is False.\n  - `geometry_format` (string): Format for returned geometry: 'mask' or 'polygon'. Default is 'mask'. When 'mask' is selected, a PNG file is created for each mask and its path is returned.\n- **Returns:** dictionary containing:\n  - `image_path`: Path to the input image\n  - `query`: The text description that was searched for\n  - `found_objects`: List of found objects, each with:\n    - `description`: The original search query\n    - `match`: The class name of the matched object\n    - `confidence`: Confidence score (0.0 to 1.0)\n    - `bbox`: Bounding box coordinates [x1, y1, x2, y2]\n    - `mask_path` (optional): Path to the PNG file for the object's mask. Included if `return_geometry` is True and `geometry_format` is 'mask'.\n    - `polygon` (optional): A list of points `[x, y]` describing the object's contour. Included if `return_geometry` is True and `geometry_format` is 'polygon'.\n  - `found`: Boolean indicating whether any objects were found\n\n**Example Claude Request:**\n\n```\nFind all dogs in my image 'photo.jpg' with a confidence threshold of 0.4\n```\n\n**Example Tool Call (JSON):**\n\n```json\n{\n  \"name\": \"find\",\n  \"arguments\": {\n    \"input_path\": \"/home/user/images/photo.jpg\",\n    \"description\": \"dog\",\n    \"confidence\": 0.4,\n    \"return_all_matches\": true,\n    \"return_geometry\": true,\n    \"geometry_format\": \"mask\"\n  }\n}\n```\n\n**Example Response (JSON):**\n\n```json\n{\n  \"result\": {\n    \"image_path\": \"/home/user/images/photo.jpg\",\n    \"query\": \"dog\",\n    \"found_objects\": [\n      {\n        \"description\": \"dog\",\n        \"match\": \"dog\",\n        \"confidence\": 0.92,\n        \"bbox\": [150.2, 30.5, 250.1, 120.7],\n        \"mask_path\": \"/home/user/images/photo_mask_0.png\"\n      },\n      {\n        \"description\": \"dog\",\n        \"match\": \"dog\",\n        \"confidence\": 0.85,\n        \"bbox\": [300.5, 150.3, 400.2, 250.1],\n        \"mask_path\": \"/home/user/images/photo_mask_1.png\"\n      }\n    ],\n    \"found\": true\n  }\n}\n```\n\n### `get_metainfo`\n\nGets metadata information about an image file.\n\n- **Required arguments:**\n  - `input_path` (string): Full path to the input image\n- **Returns:** dictionary containing metadata about the image including:\n  - `filename`\n  - `file path`\n  - `file size` (in bytes, KB, and MB)\n  - `dimensions` (width, height, aspect ratio)\n  - `image format`\n  - `color mode`\n  - `creation and modification timestamps`\n\n**Example Claude Request:**\n\n```\nGet metadata information about my image 'photo.jpg'\n```\n\n**Example Tool Call (JSON):**\n\n```json\n{\n  \"name\": \"get_metainfo\",\n  \"arguments\": {\n    \"input_path\": \"/home/user/images/photo.jpg\"\n  }\n}\n```\n\n**Example Response (JSON):**\n\n```json\n{\n  \"result\": {\n    \"filename\": \"photo.jpg\",\n    \"path\": \"/home/user/images/photo.jpg\",\n    \"size_bytes\": 12345,\n    \"size_kb\": 12.06,\n    \"size_mb\": 0.01,\n    \"dimensions\": {\n      \"width\": 800,\n      \"height\": 600,\n      \"aspect_ratio\": 1.33\n    },\n    \"format\": \"JPEG\",\n    \"color_mode\": \"RGB\",\n    \"created_at\": \"2023-06-15T10:30:45\",\n    \"modified_at\": \"2023-06-15T10:30:45\"\n  }\n}\n```\n\n\n### `ocr`\n\nPerforms Optical Character Recognition (OCR) on an image using EasyOCR. This tool extracts text from images in various languages. The default language is English, but you can specify other languages using their language codes (e.g., 'en', 'ru', 'fr', etc.).\n\n- **Required arguments:**\n  - `input_path` (string): Full path to the input image\n- **Optional arguments:**\n  - `language` (string): Language code for OCR (e.g., 'en', 'ru', 'fr', etc.). Default is 'en'\n- **Returns:** dictionary containing:\n  - `image_path`: Path to the input image\n  - `text_segments`: List of detected text segments, each with:\n    - `text`: The extracted text content\n    - `confidence`: Confidence score (0.0 to 1.0)\n    - `bbox`: Bounding box coordinates [x1, y1, x2, y2]\n\n**Example Claude Request:**\n\n```\nExtract text from my image 'document.jpg' using OCR with English language\n```\n\n**Example Tool Call (JSON):**\n\n```json\n{\n  \"name\": \"ocr\",\n  \"arguments\": {\n    \"input_path\": \"/home/user/images/document.jpg\",\n    \"language\": \"en\"\n  }\n}\n```\n\n**Example Response (JSON):**\n\n```json\n{\n  \"result\": {\n    \"image_path\": \"/home/user/images/document.jpg\",\n    \"text_segments\": [\n      {\n        \"text\": \"Hello World\",\n        \"confidence\": 0.92,\n        \"bbox\": [10.5, 20.3, 100.2, 200.1]\n      },\n      {\n        \"text\": \"Copyright 2023\",\n        \"confidence\": 0.85,\n        \"bbox\": [150.2, 30.5, 250.1, 120.7]\n      }\n    ]\n  }\n}\n```\n\n### `overlay`\n\nOverlays one image on top of another, handling transparency. This tool places an overlay image onto a base image at a specified (x, y) coordinate. If the overlay image has an alpha channel (e.g., a transparent PNG), it will be blended correctly with the base image. If the overlay extends beyond the boundaries of the base image, it will be cropped.\n\n- **Required arguments:**\n  - `base_image_path` (string): Full path to the base image\n  - `overlay_image_path` (string): Full path to the overlay image. This image can have transparency.\n  - `x` (integer): X-coordinate of the top-left corner of the overlay image on the base image.\n  - `y` (integer): Y-coordinate of the top-left corner of the overlay image on the base image.\n- **Optional arguments:**\n  - `output_path` (string): Full path to save the output image. If not provided, will use the base image filename with '_overlaid' suffix.\n- **Returns:** string (path to the resulting image)\n\n**Example Claude Request:**\n\n```\nOverlay 'logo.png' on top of 'background.jpg' at position (10, 10) and save it as 'final.jpg'\n```\n\n**Example Tool Call (JSON):\n\n```json\n{\n  \"name\": \"overlay\",\n  \"arguments\": {\n    \"base_image_path\": \"/home/user/images/background.jpg\",\n    \"overlay_image_path\": \"/home/user/images/logo.png\",\n    \"x\": 10,\n    \"y\": 10,\n    \"output_path\": \"/home/user/images/final.jpg\"\n  }\n}\n```\n\n**Example Response (JSON):**\n\n```json\n{\n  \"result\": \"/home/user/images/final.jpg\"\n}\n```\n"
  },
  {
    "path": "src/imagesorcery_mcp/tools/__init__.py",
    "content": "# Import the central logger\nfrom imagesorcery_mcp.logging_config import logger\n\nlogger.info(\"🪄 ImageSorcery MCP tools package initialized\")\n"
  },
  {
    "path": "src/imagesorcery_mcp/tools/blur.py",
    "content": "import os\nfrom typing import Annotated, Any, Dict, List, Optional\n\nimport cv2\nimport numpy as np\nfrom fastmcp import FastMCP\nfrom pydantic import Field\n\n# Import the central logger and config\nfrom imagesorcery_mcp.config import get_config\nfrom imagesorcery_mcp.logging_config import logger\n\n\ndef register_tool(mcp: FastMCP):\n    @mcp.tool()\n    def blur(\n        input_path: Annotated[str, Field(description=\"Full path to the input image (must be a full path)\")],\n        areas: Annotated[\n            List[Dict[str, Any]],\n            Field(\n                description=(\n                    \"List of areas to blur. Each area should have: \"\n                    \"a rectangle ({'x1', 'y1', 'x2', 'y2'}) or a polygon ({'polygon': [[x,y],...]}). \"\n                    \"Optionally, include 'blur_strength' (int, odd number, default 15) for each area.\"\n                )\n            ),\n        ],\n        invert_areas: Annotated[\n            bool,\n            Field(\n                description=\"If True, blurs everything EXCEPT the specified areas. Useful for background blurring.\"\n            ),\n        ] = False,\n        output_path: Annotated[\n            Optional[str],\n            Field(\n                description=(\n                    \"Full path to save the output image (must be a full path). \"\n                    \"If not provided, will use input filename \"\n                    \"with '_blurred' suffix.\"\n                )\n            ),\n        ] = None,\n    ) -> str:\n        \"\"\"\n        Blur specified rectangular or polygonal areas of an image using OpenCV.\n        \n        This tool allows blurring multiple rectangular or polygonal areas of an image with customizable\n        blur strength. Each area can be a rectangle defined by a bounding box \n        [x1, y1, x2, y2] or a polygon defined by a list of points.\n        \n        The blur_strength parameter controls the intensity of the blur effect. Higher values\n        result in stronger blur. It must be an odd number (default is 15).\n        \n        If `invert_areas` is True, the tool will blur everything EXCEPT the specified areas.\n        Returns:\n            Path to the image with blurred areas\n        \"\"\"\n        logger.info(f\"Blur tool requested for image: {input_path} with {len(areas)} areas, invert_areas={invert_areas}\")\n\n        # Check if input file exists\n        if not os.path.exists(input_path):\n            logger.error(f\"Input file not found: {input_path}\")\n            raise FileNotFoundError(f\"Input file not found: {input_path}. Please provide a full path to the file.\")\n\n        # Generate output path if not provided\n        if not output_path:\n            file_name, file_ext = os.path.splitext(input_path)\n            output_path = f\"{file_name}_blurred{file_ext}\"\n            logger.info(f\"Output path not provided, generated: {output_path}\")\n\n        # Read the image using OpenCV\n        logger.info(f\"Reading image: {input_path}\")\n        img = cv2.imread(input_path)\n        if img is None:\n            logger.error(f\"Failed to read image: {input_path}\")\n            raise ValueError(f\"Failed to read image: {input_path}\")\n        logger.info(f\"Image read successfully. Shape: {img.shape}\")\n\n        # Create a mask for the areas to be blurred (or not blurred if invert_areas is True)\n        mask = np.zeros(img.shape[:2], dtype=np.uint8)\n\n        # Populate the mask based on areas\n        for area in areas:\n            if \"polygon\" in area:\n                polygon_points = np.array(area[\"polygon\"], dtype=np.int32)\n                cv2.fillPoly(mask, [polygon_points], 255)\n            elif \"x1\" in area and \"y1\" in area and \"x2\" in area and \"y2\" in area:\n                x1, y1, x2, y2 = area[\"x1\"], area[\"y1\"], area[\"x2\"], area[\"y2\"]\n                cv2.rectangle(mask, (x1, y1), (x2, y2), 255, -1)\n            else:\n                logger.warning(\"Skipping area due to missing 'polygon' or 'x1,y1,x2,y2' keys.\")\n                continue\n\n        # If invert_areas is True, invert the mask\n        if invert_areas:\n            mask = cv2.bitwise_not(mask)\n            logger.info(\"Inverting blur areas: blurring everything EXCEPT the specified regions.\")\n        else:\n            logger.info(\"Applying blur to specified areas.\")\n\n        # Apply blur to the entire image (this will be used for the masked regions)\n        # Use the blur_strength from the first area, or config default\n        config = get_config()\n        global_blur_strength = areas[0].get(\"blur_strength\", config.blur.strength) if areas else config.blur.strength\n        if global_blur_strength % 2 == 0:\n            global_blur_strength += 1\n            logger.warning(f\"Adjusted global blur_strength to odd number: {global_blur_strength}\")\n        \n        full_blurred_img = cv2.GaussianBlur(img, (global_blur_strength, global_blur_strength), 0)\n\n        # Combine the original image and the fully blurred image using the mask\n        # Where mask is 255 (white), use the blurred image. Where mask is 0 (black), use the original image.\n        result_img = np.where(mask[:, :, None] == 255, full_blurred_img, img)\n\n        # Create directory for output if it doesn't exist\n        output_dir = os.path.dirname(output_path)\n        if output_dir and not os.path.exists(output_dir):\n            logger.info(f\"Output directory does not exist, creating: {output_dir}\")\n            os.makedirs(output_dir)\n            logger.info(f\"Output directory created: {output_dir}\")\n\n        # Save the image with blurred areas\n        logger.info(f\"Saving blurred image to: {output_path}\")\n        cv2.imwrite(output_path, result_img)\n        logger.info(f\"Blurred image saved successfully to: {output_path}\")\n\n        return output_path\n"
  },
  {
    "path": "src/imagesorcery_mcp/tools/change_color.py",
    "content": "import os\nfrom typing import Annotated, Literal, Optional\n\nimport cv2\nimport numpy as np\nfrom fastmcp import FastMCP\nfrom pydantic import Field\n\n# Import the central logger\nfrom imagesorcery_mcp.logging_config import logger\n\n\ndef register_tool(mcp: FastMCP):\n    @mcp.tool()\n    def change_color(\n        input_path: Annotated[str, Field(description=\"Full path to the input image (must be a full path)\")],\n        palette: Annotated[\n            Literal[\"grayscale\", \"sepia\"],\n            Field(description=\"The color palette to apply. Currently supports 'grayscale' and 'sepia'.\"),\n        ],\n        output_path: Annotated[\n            Optional[str],\n            Field(\n                description=(\n                    \"Full path to save the output image (must be a full path). \"\n                    \"If not provided, will use input filename \"\n                    \"with a suffix based on the palette (e.g., '_grayscale').\"\n                )\n            ),\n        ] = None,\n    ) -> str:\n        \"\"\"\n        Change the color palette of an image.\n        \n        This tool applies a predefined color transformation to an image.\n        Currently supported palettes are 'grayscale' and 'sepia'.\n        \n        Returns:\n            Path to the image with the new color palette.\n        \"\"\"\n        logger.info(f\"Change color tool requested for image: {input_path} with palette: {palette}\")\n\n        # Check if input file exists\n        if not os.path.exists(input_path):\n            logger.error(f\"Input file not found: {input_path}\")\n            raise FileNotFoundError(f\"Input file not found: {input_path}. Please provide a full path to the file.\")\n\n        # Generate output path if not provided\n        if not output_path:\n            file_name, file_ext = os.path.splitext(input_path)\n            output_path = f\"{file_name}_{palette}{file_ext}\"\n            logger.info(f\"Output path not provided, generated: {output_path}\")\n\n        # Read the image using OpenCV\n        img = cv2.imread(input_path)\n        if img is None:\n            logger.error(f\"Failed to read image: {input_path}\")\n            raise ValueError(f\"Failed to read image: {input_path}\")\n\n        # Apply the selected color palette\n        if palette == \"grayscale\":\n            logger.info(\"Applying grayscale palette\")\n            output_img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)\n        elif palette == \"sepia\":\n            logger.info(\"Applying sepia palette\")\n            # Sepia transformation matrix\n            sepia_kernel = np.array([[0.272, 0.534, 0.131], [0.349, 0.686, 0.168], [0.393, 0.769, 0.189]])\n            # Apply the transformation\n            sepia_img = cv2.transform(img, sepia_kernel)\n            # Clip values to be in the 0-255 range\n            output_img = np.clip(sepia_img, 0, 255).astype(np.uint8)\n\n        # Create directory for output if it doesn't exist\n        output_dir = os.path.dirname(output_path)\n        if output_dir and not os.path.exists(output_dir):\n            os.makedirs(output_dir)\n\n        # Save the image\n        cv2.imwrite(output_path, output_img)\n        logger.info(f\"Transformed image saved successfully to: {output_path}\")\n\n        return output_path"
  },
  {
    "path": "src/imagesorcery_mcp/tools/config.py",
    "content": "\"\"\"\nConfiguration tool for ImageSorcery MCP.\n\nThis tool allows viewing and updating configuration values through the MCP interface.\n\"\"\"\n\nfrom typing import Annotated, Any, Dict, Optional, Union\n\nfrom fastmcp import FastMCP\nfrom pydantic import Field\n\n# Import the central logger and config manager\nfrom imagesorcery_mcp.config import (\n    generate_config_documentation,\n    get_available_config_keys,\n    get_config_manager,\n)\nfrom imagesorcery_mcp.logging_config import logger\n\n\ndef _generate_config_tool_docstring() -> str:\n    \"\"\"Generate the dynamic docstring for the config tool.\"\"\"\n    base_doc = \"\"\"View or update ImageSorcery MCP configuration.\n\n        This tool allows you to:\n        - View current configuration values\n        - Update configuration values for the current session\n        - Persist configuration changes to the config file\n        - Reset runtime overrides\n\n        \"\"\"\n\n    config_doc = generate_config_documentation()\n\n    examples_doc = \"\"\"\n\n        Examples:\n        - Get all config: config(action=\"get\")\n        - Get detection confidence: config(action=\"get\", key=\"detection.confidence_threshold\")\n        - Set blur strength: config(action=\"set\", key=\"blur.strength\", value=21)\n        - Set and persist: config(action=\"set\", key=\"detection.confidence_threshold\", value=0.8, persist=True)\n        - Reset overrides: config(action=\"reset\")\n\n        Returns:\n            Dictionary containing the requested configuration data or update result\"\"\"\n\n    return base_doc + config_doc + examples_doc\n\n\ndef register_tool(mcp: FastMCP):\n    @mcp.tool()\n    def config(\n        action: Annotated[\n            str,\n            Field(\n                description=\"Action to perform: 'get' to view config, 'set' to update config, 'reset' to reset runtime overrides\",\n                pattern=\"^(get|set|reset)$\"\n            ),\n        ] = \"get\",\n        key: Annotated[\n            Optional[str],\n            Field(\n                description=(\n                    \"Configuration key to get/set. Use dot notation for nested values \"\n                    \"(e.g., 'detection.confidence_threshold', 'blur.strength'). \"\n                    \"Leave empty to get/set entire config.\"\n                )\n            ),\n        ] = None,\n        value: Annotated[\n            Optional[Union[str, int, float, bool]],\n            Field(\n                description=\"Value to set (only used with action='set')\"\n            ),\n        ] = None,\n        persist: Annotated[\n            bool,\n            Field(\n                description=\"Whether to persist changes to config file (only used with action='set')\"\n            ),\n        ] = False,\n    ) -> Dict[str, Any]:\n        \"\"\"Configuration tool - docstring will be set dynamically.\"\"\"\n        logger.info(f\"Config tool called with action='{action}', key='{key}', value='{value}', persist={persist}\")\n        \n        config_manager = get_config_manager()\n        \n        try:\n            if action == \"get\":\n                if key is None:\n                    # Return entire configuration\n                    config_dict = config_manager.get_config_dict()\n                    runtime_overrides = config_manager.get_runtime_overrides()\n                    \n                    result = {\n                        \"action\": \"get\",\n                        \"config\": config_dict,\n                        \"runtime_overrides\": runtime_overrides,\n                        \"config_file\": str(config_manager.config_file),\n                        \"message\": \"Current configuration retrieved successfully\"\n                    }\n                    logger.info(\"Retrieved entire configuration\")\n                    return result\n                else:\n                    # Return specific configuration value\n                    config_dict = config_manager.get_config_dict()\n                    \n                    # Navigate to the requested key\n                    if '.' in key:\n                        parts = key.split('.')\n                        current = config_dict\n                        for part in parts:\n                            if part not in current:\n                                raise KeyError(f\"Configuration key '{key}' not found\")\n                            current = current[part]\n                        value_result = current\n                    else:\n                        if key not in config_dict:\n                            raise KeyError(f\"Configuration key '{key}' not found\")\n                        value_result = config_dict[key]\n                    \n                    result = {\n                        \"action\": \"get\",\n                        \"key\": key,\n                        \"value\": value_result,\n                        \"message\": f\"Configuration value for '{key}' retrieved successfully\"\n                    }\n                    logger.info(f\"Retrieved configuration value for key '{key}': {value_result}\")\n                    return result\n            \n            elif action == \"set\":\n                if key is None:\n                    raise ValueError(\"Key is required for 'set' action\")\n                if value is None:\n                    raise ValueError(\"Value is required for 'set' action\")\n                \n                # Prepare update dictionary\n                updates = {key: value}\n                \n                # Update configuration\n                updated_config = config_manager.update_config(updates, persist=persist)\n                \n                # Get the updated value for confirmation\n                if '.' in key:\n                    parts = key.split('.')\n                    current = updated_config\n                    for part in parts:\n                        current = current[part]\n                    new_value = current\n                else:\n                    new_value = updated_config[key]\n                \n                result = {\n                    \"action\": \"set\",\n                    \"key\": key,\n                    \"old_value\": value,  # This is the input value\n                    \"new_value\": new_value,  # This is the validated/processed value\n                    \"persisted\": persist,\n                    \"message\": f\"Configuration '{key}' updated successfully\" + (\" and persisted to file\" if persist else \" for current session\")\n                }\n                \n                logger.info(f\"Updated configuration '{key}' to '{new_value}'\" + (\" (persisted)\" if persist else \" (runtime only)\"))\n                return result\n            \n            elif action == \"reset\":\n                # Reset runtime overrides\n                config_manager.reset_runtime_overrides()\n                \n                result = {\n                    \"action\": \"reset\",\n                    \"message\": \"Runtime configuration overrides reset successfully\",\n                    \"config\": config_manager.get_config_dict()\n                }\n                \n                logger.info(\"Reset runtime configuration overrides\")\n                return result\n            \n            else:\n                raise ValueError(f\"Invalid action '{action}'. Must be 'get', 'set', or 'reset'\")\n        \n        except KeyError as e:\n            logger.error(f\"Configuration key error: {e}\")\n            return {\n                \"action\": action,\n                \"error\": str(e),\n                \"available_keys\": get_available_config_keys()\n            }\n        \n        except ValueError as e:\n            logger.error(f\"Configuration value error: {e}\")\n            return {\n                \"action\": action,\n                \"error\": str(e),\n                \"message\": \"Please check the provided key and value\"\n            }\n        \n        except Exception as e:\n            logger.error(f\"Configuration tool error: {e}\", exc_info=True)\n            return {\n                \"action\": action,\n                \"error\": f\"Configuration operation failed: {str(e)}\",\n                \"message\": \"An unexpected error occurred while processing the configuration request\"\n            }\n\n    # Set the dynamic docstring\n    config.__doc__ = _generate_config_tool_docstring()\n"
  },
  {
    "path": "src/imagesorcery_mcp/tools/crop.py",
    "content": "import os\nfrom typing import Annotated\n\nimport cv2\nfrom fastmcp import FastMCP\nfrom pydantic import Field\n\n# Import the central logger\nfrom imagesorcery_mcp.logging_config import logger\n\n\ndef register_tool(mcp: FastMCP):\n    @mcp.tool()\n    def crop(\n        input_path: Annotated[str, Field(description=\"Full path to the input image (must be a full path)\")],\n        x1: Annotated[\n            int,\n            Field(description=\"X-coordinate of the top-left corner\"),\n        ],\n        y1: Annotated[\n            int,\n            Field(description=\"Y-coordinate of the top-left corner\"),\n        ],\n        x2: Annotated[\n            int,\n            Field(description=\"X-coordinate of the bottom-right corner\"),\n        ],\n        y2: Annotated[\n            int,\n            Field(description=\"Y-coordinate of the bottom-right corner\"),\n        ],\n        output_path: Annotated[\n            str,\n            Field(\n                description=(\n                    \"Full path to save the output image (must be a full path). \"\n                    \"If not provided, will use input filename \"\n                    \"with '_cropped' suffix.\"\n                )\n            ),\n        ] = None,\n    ) -> str:\n        \"\"\"\n        Crop an image using OpenCV's NumPy slicing approach\n        with OpenMCP's bounding box annotations.\n\n        Returns:\n            Path to the cropped image\n        \"\"\"\n        logger.info(f\"Crop tool requested for image: {input_path} with region [{x1}, {y1}, {x2}, {y2}]\")\n\n        # Check if input file exists\n        if not os.path.exists(input_path):\n            logger.error(f\"Input file not found: {input_path}\")\n            raise FileNotFoundError(f\"Input file not found: {input_path}. Please provide a full path to the file.\")\n\n        # Generate output path if not provided\n        if not output_path:\n            file_name, file_ext = os.path.splitext(input_path)\n            output_path = f\"{file_name}_cropped{file_ext}\"\n            logger.info(f\"Output path not provided, generated: {output_path}\")\n\n        # Read the image using OpenCV\n        logger.info(f\"Reading image: {input_path}\")\n        img = cv2.imread(input_path)\n        if img is None:\n            logger.error(f\"Failed to read image: {input_path}\")\n            raise ValueError(f\"Failed to read image: {input_path}\")\n        logger.info(f\"Image read successfully. Shape: {img.shape}\")\n\n        # Crop the image using NumPy slicing\n        logger.info(f\"Cropping image with region [{x1}, {y1}, {x2}, {y2}]\")\n        cropped_img = img[y1:y2, x1:x2]\n        logger.info(f\"Image cropped successfully. New shape: {cropped_img.shape}\")\n\n        # Create directory for output if it doesn't exist\n        output_dir = os.path.dirname(output_path)\n        if output_dir and not os.path.exists(output_dir):\n            logger.info(f\"Output directory does not exist, creating: {output_dir}\")\n            os.makedirs(output_dir)\n            logger.info(f\"Output directory created: {output_dir}\")\n\n        # Save the cropped image\n        logger.info(f\"Saving cropped image to: {output_path}\")\n        cv2.imwrite(output_path, cropped_img)\n        logger.info(f\"Cropped image saved successfully to: {output_path}\")\n\n        return output_path\n"
  },
  {
    "path": "src/imagesorcery_mcp/tools/detect.py",
    "content": "import os\nfrom pathlib import Path\nfrom typing import Annotated, Any, Dict, List, Literal, Optional, Union\n\nimport cv2\nimport numpy as np\nfrom fastmcp import FastMCP\nfrom pydantic import Field\n\n# Import the central logger and config\nfrom imagesorcery_mcp.config import get_config\nfrom imagesorcery_mcp.logging_config import logger\n\n\ndef get_model_path(model_name):\n    \"\"\"Get the path to a model in the models directory.\"\"\"\n    logger.info(f\"Attempting to get path for model: {model_name}\")\n    model_path = Path(\"models\") / model_name\n    if model_path.exists():\n        logger.info(f\"Model found at: {model_path}\")\n        return str(model_path)\n    logger.warning(f\"Model not found in models directory: {model_name}\")\n    return None\n\n\ndef register_tool(mcp: FastMCP):\n    @mcp.tool()\n    def detect(\n        input_path: Annotated[str, Field(description=\"Full path to the input image (must be a full path)\")],\n        confidence: Annotated[\n            Optional[float],\n            Field(\n                description=\"Confidence threshold for detection (0.0 to 1.0). If not provided, uses config default.\",\n                ge=0.0,\n                le=1.0,\n            ),\n        ] = None,\n        model_name: Annotated[\n            Optional[str],\n            Field(\n                description=\"Model name to use for detection (e.g., 'yoloe-11l-seg-pf.pt', 'yolov8m.pt'). If not provided, uses config default.\",\n            ),\n        ] = None,\n        return_geometry: Annotated[\n            bool, Field(description=\"If True, returns segmentation masks or polygons for detected objects.\")\n        ] = False,\n        geometry_format: Annotated[\n            Literal[\"mask\", \"polygon\"], Field(description=\"Format for returned geometry: 'mask' or 'polygon'.\")\n        ] = \"mask\",\n    ) -> Dict[str, Union[str, List[Dict[str, Any]]]]:\n        \"\"\"\n        Detect objects in an image using models from Ultralytics.\n\n        This tool requires pre-downloaded models. Use the download-yolo-models\n        command to download models before using this tool.\n\n        If objects aren't common, consider using a specialized model.\n\n        This tool can optionally return segmentation masks or polygons if a segmentation\n        model (e.g., one ending in '-seg.pt') is used.\n\n        When 'mask' is chosen for geometry_format, a PNG file is created for each\n        detected object's mask. The file path is returned in the 'mask_path' field.\n\n        Returns:\n            Dictionary containing the input image path and a list of detected objects.\n            Each object includes its class name, confidence score, and bounding box.\n            If return_geometry is True, it also includes a 'mask_path' (path to a PNG file) or\n            'polygon' (list of points).\n        \"\"\"\n        # Get configuration defaults\n        config = get_config()\n\n        # Use config defaults if parameters not provided\n        if confidence is None:\n            confidence = config.detection.confidence_threshold\n            logger.info(f\"Using config default confidence: {confidence}\")\n\n        if model_name is None:\n            model_name = config.detection.default_model\n            logger.info(f\"Using config default model: {model_name}\")\n\n        logger.info(\n            f\"Detect tool requested for image: {input_path} with model: {model_name} and confidence: {confidence}\")\n\n        # Check if input file exists\n        if not os.path.exists(input_path):\n            logger.error(f\"Input file not found: {input_path}\")\n            raise FileNotFoundError(f\"Input file not found: {input_path}. Please provide a full path to the file.\")\n\n        # Add .pt extension if it doesn't exist\n        if not model_name.endswith(\".pt\"):\n            original_model_name = model_name\n            model_name = f\"{model_name}.pt\"\n            logger.info(f\"Added .pt extension to model name: {original_model_name} -> {model_name}\")\n\n        # Try to find the model\n        model_path = get_model_path(model_name)\n\n        # If model not found, raise an error with helpful message\n        if not model_path:\n            logger.error(f\"Model {model_name} not found.\")\n            # List available models\n            available_models = []\n            models_dir = Path(\"models\")\n            \n            # Find all .pt files in the models directory and its subdirectories\n            if models_dir.exists():\n                for file in models_dir.glob(\"**/*.pt\"):\n                    available_models.append(str(file.relative_to(models_dir)))\n\n            error_msg = (\n                f\"Model {model_name} not found. \"\n                f\"Available local models: \"\n                f\"{', '.join(available_models) if available_models else 'None'}\\n\"\n                \"To use this tool, you need to download the model first using:\\n\"\n                \"download-yolo-models --ultralytics MODEL_NAME\\n\"\n                \"or\\n\"\n                \"download-yolo-models --huggingface REPO_ID[:FILENAME]\\n\"\n                \"Models will be downloaded to the 'models' directory \"\n                \"in the project root.\"\n            )\n            raise RuntimeError(error_msg)\n\n        try:\n            # Set environment variable to use the models directory\n            os.environ[\"YOLO_CONFIG_DIR\"] = str(Path(\"models\").absolute())\n            logger.info(f\"Set YOLO_CONFIG_DIR environment variable to: {os.environ['YOLO_CONFIG_DIR']}\")\n\n            # Import here to avoid loading ultralytics if not needed\n            logger.info(\"Importing Ultralytics\")\n            from ultralytics import YOLO\n            logger.info(\"Ultralytics imported successfully\")\n\n            # Load the model from the found path\n            logger.info(f\"Loading model from: {model_path}\")\n            model = YOLO(model_path)\n            logger.info(\"Model loaded successfully\")\n\n            # Run inference on the image\n            logger.info(f\"Running inference on {input_path} with confidence {confidence}\")\n            results = model(input_path, conf=confidence)[0]\n            logger.info(f\"Inference completed. Found {len(results.boxes)} detections.\")\n\n            if return_geometry and results.masks is None:\n                raise ValueError(\n                    f\"Model '{model_name}' does not support segmentation, but return_geometry=True was requested. \"\n                    \"Please use a segmentation model (e.g., one ending in '-seg.pt').\"\n                )\n\n            # Process results\n            detections = []\n            for i, box in enumerate(results.boxes):\n                # Get class name\n                class_id = int(box.cls.item())\n                class_name = results.names[class_id]\n\n                # Get confidence score\n                conf = float(box.conf.item())\n\n                # Get bounding box coordinates (x1, y1, x2, y2)\n                x1, y1, x2, y2 = [float(coord) for coord in box.xyxy[0].tolist()]\n\n                detection_item = {\"class\": class_name, \"confidence\": conf, \"bbox\": [x1, y1, x2, y2]}\n\n                if return_geometry:\n                    if geometry_format == \"mask\":\n                        # Convert mask to a savable format\n                        mask = results.masks.data[i].cpu().numpy()\n                        mask_image = (mask * 255).astype(np.uint8)\n\n                        # Generate a unique filename for the mask, always with .png extension\n                        input_p = Path(input_path)\n                        base_name = input_p.stem\n                        output_dir = input_p.parent\n                        mask_output_path = output_dir / f\"{base_name}_mask_{i}.png\"\n\n                        # Save the mask as a PNG file\n                        try:\n                            success = cv2.imwrite(str(mask_output_path), mask_image)\n                            if success:\n                                logger.info(f\"Saved detection mask to {mask_output_path}\")\n                                detection_item[\"mask_path\"] = str(mask_output_path)\n                            else:\n                                logger.error(f\"Failed to save mask to {mask_output_path}\")\n                        except Exception as e:\n                            logger.error(f\"An unexpected error occurred while saving mask to {mask_output_path}: {e}\")\n\n                    elif geometry_format == \"polygon\":\n                        # Ultralytics masks.xy are lists of polygons\n                        polygon = results.masks.xy[i].tolist()\n                        detection_item[\"polygon\"] = polygon\n\n                detections.append(detection_item)\n                logger.debug(\n                    f\"Detected: class={class_name}, confidence={conf:.2f}, bbox=[{x1:.2f}, {y1:.2f}, {x2:.2f}, {y2:.2f}]\")\n\n            logger.info(f\"Detection completed successfully for {input_path}\")\n            return {\"image_path\": input_path, \"detections\": detections}\n\n        except Exception as e:\n            # Provide more helpful error message\n            error_msg = f\"Error running object detection: {str(e)}\\n\"\n            logger.error(f\"Error during object detection: {str(e)}\", exc_info=True)\n\n            if \"not found\" in str(e).lower():\n                error_msg += (\n                    \"The model could not be found. \"\n                    \"Please download it first using: \"\n                    \"download-yolo-models --ultralytics MODEL_NAME\"\n                )\n            elif \"permission denied\" in str(e).lower():\n                error_msg += (\n                    \"Permission denied when trying to access or create the models \"\n                    \"directory.\\n\"\n                    \"Try running the command with appropriate permissions.\"\n                )\n\n            raise RuntimeError(error_msg) from e\n"
  },
  {
    "path": "src/imagesorcery_mcp/tools/draw_arrows.py",
    "content": "import os\nfrom typing import Annotated, Any, Dict, List, Optional\n\nimport cv2\nfrom fastmcp import FastMCP\nfrom pydantic import Field\n\n# Import the central logger\nfrom imagesorcery_mcp.logging_config import logger\n\n\ndef register_tool(mcp: FastMCP):\n    @mcp.tool()\n    def draw_arrows(\n        input_path: Annotated[str, Field(description=\"Full path to the input image (must be a full path)\")],\n        arrows: Annotated[\n            List[Dict[str, Any]],\n            Field(\n                description=(\n                    \"List of arrow items to draw. Each item should have: \"\n                    \"'x1' (int), 'y1' (int) - start point, \"\n                    \"'x2' (int), 'y2' (int) - end point, and optionally \"\n                    \"'color' (list of 3 ints [B,G,R]), \"\n                    \"'thickness' (int), 'tip_length' (float, relative to arrow length)\"\n                )\n            ),\n        ],\n        output_path: Annotated[\n            Optional[str],\n            Field(\n                description=(\n                    \"Full path to save the output image (must be a full path). \"\n                    \"If not provided, will use input filename \"\n                    \"with '_with_arrows' suffix.\"\n                )\n            ),\n        ] = None,\n    ) -> str:\n        \"\"\"\n        Draw arrows on an image using OpenCV.\n        \n        This tool allows adding multiple arrows to an image with customizable\n        start and end points, color, thickness, and tip length.\n        \n        Each arrow is defined by its start point (x1, y1) and end point (x2, y2).\n        The 'tip_length' is relative to the arrow's length (e.g., 0.1 means 10%).\n        \n        Returns:\n            Path to the image with drawn arrows\n        \"\"\"\n        logger.info(f\"Draw arrows tool requested for image: {input_path} with {len(arrows)} arrows\")\n\n        # Check if input file exists\n        if not os.path.exists(input_path):\n            logger.error(f\"Input file not found: {input_path}\")\n            raise FileNotFoundError(f\"Input file not found: {input_path}. Please provide a full path to the file.\")\n\n        # Generate output path if not provided\n        if not output_path:\n            file_name, file_ext = os.path.splitext(input_path)\n            output_path = f\"{file_name}_with_arrows{file_ext}\"\n            logger.info(f\"Output path not provided, generated: {output_path}\")\n\n        # Read the image using OpenCV\n        logger.info(f\"Reading image: {input_path}\")\n        img = cv2.imread(input_path)\n        if img is None:\n            logger.error(f\"Failed to read image: {input_path}\")\n            raise ValueError(f\"Failed to read image: {input_path}\")\n        logger.info(f\"Image read successfully. Shape: {img.shape}\")\n\n        # Draw each arrow on the image\n        for i, arrow_item in enumerate(arrows):\n            x1, y1 = arrow_item[\"x1\"], arrow_item[\"y1\"]\n            x2, y2 = arrow_item[\"x2\"], arrow_item[\"y2\"]\n            \n            color = arrow_item.get(\"color\", [0, 0, 0])  # Default: black\n            thickness = arrow_item.get(\"thickness\", 1)\n            tip_length = arrow_item.get(\"tip_length\", 0.1)\n            \n            logger.debug(f\"Drawing arrow {i+1}: from ({x1},{y1}) to ({x2},{y2}), color={color}, thickness={thickness}, tip_length={tip_length}\")\n\n            cv2.arrowedLine(img, (x1, y1), (x2, y2), color, thickness, tipLength=tip_length)\n            logger.debug(f\"Arrow {i+1} drawn\")\n\n        # Create directory for output if it doesn't exist\n        output_dir = os.path.dirname(output_path)\n        if output_dir and not os.path.exists(output_dir):\n            logger.info(f\"Output directory does not exist, creating: {output_dir}\")\n            os.makedirs(output_dir)\n            logger.info(f\"Output directory created: {output_dir}\")\n\n        cv2.imwrite(output_path, img)\n        logger.info(f\"Image with arrows saved successfully to: {output_path}\")\n\n        return output_path\n"
  },
  {
    "path": "src/imagesorcery_mcp/tools/draw_circle.py",
    "content": "import os\nfrom typing import Annotated, List, Optional\n\nimport cv2\nfrom fastmcp import FastMCP\nfrom pydantic import BaseModel, Field\n\n# Import the central logger\nfrom imagesorcery_mcp.logging_config import logger\n\n\nclass CircleItem(BaseModel):\n    \"\"\"Represents a circle to be drawn on an image.\"\"\"\n    center_x: Annotated[int, Field(description=\"X-coordinate of the circle's center\")]\n    center_y: Annotated[int, Field(description=\"Y-coordinate of the circle's center\")]\n    radius: Annotated[int, Field(description=\"Radius of the circle\")]\n    color: Annotated[List[int], Field(description=\"Color in BGR format [B,G,R]\")] = [0, 0, 0]  # Default: black\n    thickness: Annotated[int, Field(description=\"Line thickness. Use -1 for a filled circle.\")] = 1\n    filled: Annotated[bool, Field(description=\"Whether to fill the circle. If true, thickness is set to -1.\")] = False\n\n\ndef register_tool(mcp: FastMCP):\n    @mcp.tool()\n    def draw_circles(\n        input_path: Annotated[str, Field(description=\"Full path to the input image (must be a full path)\")],\n        circles: Annotated[\n            List[CircleItem],\n            Field(\n                description=(\n                    \"List of circle items to draw. Each item should have: \"\n                    \"'center_x' (int), 'center_y' (int), 'radius' (int), and optionally \"\n                    \"'color' (list of 3 ints [B,G,R]), \"\n                    \"'thickness' (int), 'filled' (bool)\"\n                )\n            ),\n        ],\n        output_path: Annotated[\n            Optional[str],\n            Field(\n                description=(\n                    \"Full path to save the output image (must be a full path). \"\n                    \"If not provided, will use input filename \"\n                    \"with '_with_circles' suffix.\"\n                )\n            ),\n        ] = None,\n    ) -> str:\n        \"\"\"\n        Draw circles on an image using OpenCV.\n        \n        This tool allows adding multiple circles to an image with customizable\n        center, radius, color, thickness, and fill option.\n        \n        Each circle is defined by its center coordinates (center_x, center_y) and radius.\n        \n        Returns:\n            Path to the image with drawn circles\n        \"\"\"\n        logger.info(f\"Draw circles tool requested for image: {input_path} with {len(circles)} circles\")\n\n        # Check if input file exists\n        if not os.path.exists(input_path):\n            logger.error(f\"Input file not found: {input_path}\")\n            raise FileNotFoundError(f\"Input file not found: {input_path}. Please provide a full path to the file.\")\n\n        # Generate output path if not provided\n        if not output_path:\n            file_name, file_ext = os.path.splitext(input_path)\n            output_path = f\"{file_name}_with_circles{file_ext}\"\n            logger.info(f\"Output path not provided, generated: {output_path}\")\n\n        # Read the image using OpenCV\n        logger.info(f\"Reading image: {input_path}\")\n        img = cv2.imread(input_path)\n        if img is None:\n            logger.error(f\"Failed to read image: {input_path}\")\n            raise ValueError(f\"Failed to read image: {input_path}\")\n        logger.info(f\"Image read successfully. Shape: {img.shape}\")\n\n        # Draw each circle on the image\n        for i, circle_item in enumerate(circles):\n            center_x = circle_item.center_x\n            center_y = circle_item.center_y\n            radius = circle_item.radius\n            color = circle_item.color\n            thickness = circle_item.thickness\n            filled = circle_item.filled\n            \n            logger.debug(f\"Drawing circle {i+1}: center=({center_x}, {center_y}), radius={radius}, color={color}, thickness={thickness}, filled={filled}\")\n\n            if filled:\n                thickness = -1\n            \n            cv2.circle(img, (center_x, center_y), radius, color, thickness)\n            logger.debug(f\"Circle {i+1} drawn\")\n\n        output_dir = os.path.dirname(output_path)\n        if output_dir and not os.path.exists(output_dir):\n            os.makedirs(output_dir)\n            logger.info(f\"Output directory created: {output_dir}\")\n\n        cv2.imwrite(output_path, img)\n        logger.info(f\"Image with circles saved successfully to: {output_path}\")\n\n        return output_path\n"
  },
  {
    "path": "src/imagesorcery_mcp/tools/draw_lines.py",
    "content": "import os\nfrom typing import Annotated, Any, Dict, List, Optional\n\nimport cv2\nfrom fastmcp import FastMCP\nfrom pydantic import Field\n\n# Import the central logger\nfrom imagesorcery_mcp.logging_config import logger\n\n\ndef register_tool(mcp: FastMCP):\n    @mcp.tool()\n    def draw_lines(\n        input_path: Annotated[str, Field(description=\"Full path to the input image (must be a full path)\")],\n        lines: Annotated[\n            List[Dict[str, Any]],\n            Field(\n                description=(\n                    \"List of line items to draw. Each item should have: \"\n                    \"'x1' (int), 'y1' (int) - start point, \"\n                    \"'x2' (int), 'y2' (int) - end point, and optionally \"\n                    \"'color' (list of 3 ints [B,G,R]), \"\n                    \"'thickness' (int)\"\n                )\n            ),\n        ],\n        output_path: Annotated[\n            Optional[str],\n            Field(\n                description=(\n                    \"Full path to save the output image (must be a full path). \"\n                    \"If not provided, will use input filename \"\n                    \"with '_with_lines' suffix.\"\n                )\n            ),\n        ] = None,\n    ) -> str:\n        \"\"\"\n        Draw lines on an image using OpenCV.\n        \n        This tool allows adding multiple lines to an image with customizable\n        start and end points, color, and thickness.\n        \n        Each line is defined by its start point (x1, y1) and end point (x2, y2).\n        \n        Returns:\n            Path to the image with drawn lines\n        \"\"\"\n        logger.info(f\"Draw lines tool requested for image: {input_path} with {len(lines)} lines\")\n\n        # Check if input file exists\n        if not os.path.exists(input_path):\n            logger.error(f\"Input file not found: {input_path}\")\n            raise FileNotFoundError(f\"Input file not found: {input_path}. Please provide a full path to the file.\")\n\n        # Generate output path if not provided\n        if not output_path:\n            file_name, file_ext = os.path.splitext(input_path)\n            output_path = f\"{file_name}_with_lines{file_ext}\"\n            logger.info(f\"Output path not provided, generated: {output_path}\")\n\n        # Read the image using OpenCV\n        logger.info(f\"Reading image: {input_path}\")\n        img = cv2.imread(input_path)\n        if img is None:\n            logger.error(f\"Failed to read image: {input_path}\")\n            raise ValueError(f\"Failed to read image: {input_path}\")\n        logger.info(f\"Image read successfully. Shape: {img.shape}\")\n\n        # Draw each line on the image\n        for i, line_item in enumerate(lines):\n            x1, y1 = line_item[\"x1\"], line_item[\"y1\"]\n            x2, y2 = line_item[\"x2\"], line_item[\"y2\"]\n            \n            color = line_item.get(\"color\", [0, 0, 0])  # Default: black\n            thickness = line_item.get(\"thickness\", 1)\n            \n            logger.debug(f\"Drawing line {i+1}: from ({x1},{y1}) to ({x2},{y2}), color={color}, thickness={thickness}\")\n\n            cv2.line(img, (x1, y1), (x2, y2), color, thickness)\n            logger.debug(f\"Line {i+1} drawn\")\n\n        # Create directory for output if it doesn't exist\n        output_dir = os.path.dirname(output_path)\n        if output_dir and not os.path.exists(output_dir):\n            logger.info(f\"Output directory does not exist, creating: {output_dir}\")\n            os.makedirs(output_dir)\n            logger.info(f\"Output directory created: {output_dir}\")\n\n        cv2.imwrite(output_path, img)\n        logger.info(f\"Image with lines saved successfully to: {output_path}\")\n\n        return output_path\n"
  },
  {
    "path": "src/imagesorcery_mcp/tools/draw_rectangle.py",
    "content": "import os\nfrom typing import Annotated, Any, Dict, List, Optional\n\nimport cv2\nfrom fastmcp import FastMCP\nfrom pydantic import Field\n\n# Import the central logger\nfrom imagesorcery_mcp.logging_config import logger\n\n\ndef register_tool(mcp: FastMCP):\n    @mcp.tool()\n    def draw_rectangles(\n        input_path: Annotated[str, Field(description=\"Full path to the input image (must be a full path)\")],\n        rectangles: Annotated[\n            List[Dict[str, Any]],\n            Field(\n                description=(\n                    \"List of rectangle items to draw. Each item should have: \"\n                    \"'x1' (int), 'y1' (int), 'x2' (int), 'y2' (int), and optionally \"\n                    \"'color' (list of 3 ints [B,G,R]), \"\n                    \"'thickness' (int), 'filled' (bool)\"\n                )\n            ),\n        ],\n        output_path: Annotated[\n            Optional[str],\n            Field(\n                description=(\n                    \"Full path to save the output image (must be a full path). \"\n                    \"If not provided, will use input filename \"\n                    \"with '_with_rectangles' suffix.\"\n                )\n            ),\n        ] = None,\n    ) -> str:\n        \"\"\"\n        Draw rectangles on an image using OpenCV.\n        \n        This tool allows adding multiple rectangles to an image with customizable\n        position, color, thickness, and fill option.\n        \n        Each rectangle is defined by two points: (x1, y1) for the top-left corner\n        and (x2, y2) for the bottom-right corner.\n        \n        Returns:\n            Path to the image with drawn rectangles\n        \"\"\"\n        logger.info(f\"Draw rectangles tool requested for image: {input_path} with {len(rectangles)} rectangles\")\n\n        # Check if input file exists\n        if not os.path.exists(input_path):\n            logger.error(f\"Input file not found: {input_path}\")\n            raise FileNotFoundError(f\"Input file not found: {input_path}. Please provide a full path to the file.\")\n\n        # Generate output path if not provided\n        if not output_path:\n            file_name, file_ext = os.path.splitext(input_path)\n            output_path = f\"{file_name}_with_rectangles{file_ext}\"\n            logger.info(f\"Output path not provided, generated: {output_path}\")\n\n        # Read the image using OpenCV\n        logger.info(f\"Reading image: {input_path}\")\n        img = cv2.imread(input_path)\n        if img is None:\n            logger.error(f\"Failed to read image: {input_path}\")\n            raise ValueError(f\"Failed to read image: {input_path}\")\n        logger.info(f\"Image read successfully. Shape: {img.shape}\")\n\n        # Draw each rectangle on the image\n        for i, rect_item in enumerate(rectangles):\n            # Extract rectangle coordinates (required)\n            x1 = rect_item[\"x1\"]\n            y1 = rect_item[\"y1\"]\n            x2 = rect_item[\"x2\"]\n            y2 = rect_item[\"y2\"]\n            \n            # Extract optional parameters with defaults\n            color = rect_item.get(\"color\", [0, 0, 0])  # Default: black\n            thickness = rect_item.get(\"thickness\", 1)\n            filled = rect_item.get(\"filled\", False)\n            \n            logger.debug(f\"Drawing rectangle {i+1}: x1={x1}, y1={y1}, x2={x2}, y2={y2}, color={color}, thickness={thickness}, filled={filled}\")\n\n            # If filled is True, set thickness to -1 (OpenCV's way of filling shapes)\n            if filled:\n                thickness = -1\n            \n            # Draw the rectangle on the image\n            cv2.rectangle(\n                img, \n                (x1, y1), \n                (x2, y2), \n                color, \n                thickness\n            )\n            logger.debug(f\"Rectangle {i+1} drawn\")\n\n        # Create directory for output if it doesn't exist\n        output_dir = os.path.dirname(output_path)\n        if output_dir and not os.path.exists(output_dir):\n            logger.info(f\"Output directory does not exist, creating: {output_dir}\")\n            os.makedirs(output_dir)\n            logger.info(f\"Output directory created: {output_dir}\")\n\n        # Save the image with rectangles\n        logger.info(f\"Saving image with rectangles to: {output_path}\")\n        cv2.imwrite(output_path, img)\n        logger.info(f\"Image with rectangles saved successfully to: {output_path}\")\n\n        return output_path\n"
  },
  {
    "path": "src/imagesorcery_mcp/tools/draw_text.py",
    "content": "import os\nfrom typing import Annotated, Any, Dict, List, Optional\n\nimport cv2\nfrom fastmcp import FastMCP\nfrom pydantic import Field\n\n# Import the central logger and config\nfrom imagesorcery_mcp.config import get_config\nfrom imagesorcery_mcp.logging_config import logger\n\n\ndef register_tool(mcp: FastMCP):\n    @mcp.tool()\n    def draw_texts(\n        input_path: Annotated[str, Field(description=\"Full path to the input image (must be a full path)\")],\n        texts: Annotated[\n            List[Dict[str, Any]],\n            Field(\n                description=(\n                    \"List of text items to draw. Each item should have: \"\n                    \"'text' (string), 'x' (int), 'y' (int), and optionally \"\n                    \"'font_scale' (float), 'color' (list of 3 ints [B,G,R]), \"\n                    \"'thickness' (int), 'font_face' (string)\"\n                )\n            ),\n        ],\n        output_path: Annotated[\n            Optional[str],\n            Field(\n                description=(\n                    \"Full path to save the output image (must be a full path). \"\n                    \"If not provided, will use input filename \"\n                    \"with '_with_text' suffix.\"\n                )\n            ),\n        ] = None,\n    ) -> str:\n        \"\"\"\n        Draw text on an image using OpenCV.\n        \n        This tool allows adding multiple text elements to an image with customizable\n        position, font, size, color, and thickness.\n        \n        Available font_face options:\n        - 'FONT_HERSHEY_SIMPLEX' (default)\n        - 'FONT_HERSHEY_PLAIN'\n        - 'FONT_HERSHEY_DUPLEX'\n        - 'FONT_HERSHEY_COMPLEX'\n        - 'FONT_HERSHEY_TRIPLEX'\n        - 'FONT_HERSHEY_COMPLEX_SMALL'\n        - 'FONT_HERSHEY_SCRIPT_SIMPLEX'\n        - 'FONT_HERSHEY_SCRIPT_COMPLEX'\n        \n        Returns:\n            Path to the image with drawn text\n        \"\"\"\n        logger.info(f\"Draw texts tool requested for image: {input_path} with {len(texts)} text items\")\n\n        # Check if input file exists\n        if not os.path.exists(input_path):\n            logger.error(f\"Input file not found: {input_path}\")\n            raise FileNotFoundError(f\"Input file not found: {input_path}. Please provide a full path to the file.\")\n\n        # Generate output path if not provided\n        if not output_path:\n            file_name, file_ext = os.path.splitext(input_path)\n            output_path = f\"{file_name}_with_text{file_ext}\"\n            logger.info(f\"Output path not provided, generated: {output_path}\")\n\n        # Read the image using OpenCV\n        logger.info(f\"Reading image: {input_path}\")\n        img = cv2.imread(input_path)\n        if img is None:\n            logger.error(f\"Failed to read image: {input_path}\")\n            raise ValueError(f\"Failed to read image: {input_path}\")\n        logger.info(f\"Image read successfully. Shape: {img.shape}\")\n\n        # Font face mapping\n        font_faces = {\n            \"FONT_HERSHEY_SIMPLEX\": cv2.FONT_HERSHEY_SIMPLEX,\n            \"FONT_HERSHEY_PLAIN\": cv2.FONT_HERSHEY_PLAIN,\n            \"FONT_HERSHEY_DUPLEX\": cv2.FONT_HERSHEY_DUPLEX,\n            \"FONT_HERSHEY_COMPLEX\": cv2.FONT_HERSHEY_COMPLEX,\n            \"FONT_HERSHEY_TRIPLEX\": cv2.FONT_HERSHEY_TRIPLEX,\n            \"FONT_HERSHEY_COMPLEX_SMALL\": cv2.FONT_HERSHEY_COMPLEX_SMALL,\n            \"FONT_HERSHEY_SCRIPT_SIMPLEX\": cv2.FONT_HERSHEY_SCRIPT_SIMPLEX,\n            \"FONT_HERSHEY_SCRIPT_COMPLEX\": cv2.FONT_HERSHEY_SCRIPT_COMPLEX,\n        }\n        logger.debug(\"OpenCV font face mapping created\")\n\n        # Get configuration defaults\n        config = get_config()\n\n        # Draw each text item on the image\n        for i, text_item in enumerate(texts):\n            # Extract text and position (required)\n            text = text_item[\"text\"]\n            x = text_item[\"x\"]\n            y = text_item[\"y\"]\n\n            # Extract optional parameters with config defaults\n            font_scale = text_item.get(\"font_scale\", config.text.font_scale)\n            color = text_item.get(\"color\", config.drawing.color)\n            thickness = text_item.get(\"thickness\", config.drawing.thickness)\n            \n            # Get font face (default to SIMPLEX if not specified or invalid)\n            font_face_name = text_item.get(\"font_face\", \"FONT_HERSHEY_SIMPLEX\")\n            font_face = font_faces.get(font_face_name, cv2.FONT_HERSHEY_SIMPLEX)\n            \n            logger.debug(f\"Drawing text {i+1}: '{text}' at ({x}, {y}) with font_scale={font_scale}, color={color}, thickness={thickness}, font_face={font_face_name}\")\n\n            # Draw the text on the image\n            cv2.putText(\n                img, \n                text, \n                (x, y), \n                font_face, \n                font_scale, \n                color, \n                thickness\n            )\n            logger.debug(f\"Text {i+1} drawn\")\n\n        # Create directory for output if it doesn't exist\n        output_dir = os.path.dirname(output_path)\n        if output_dir and not os.path.exists(output_dir):\n            logger.info(f\"Output directory does not exist, creating: {output_dir}\")\n            os.makedirs(output_dir)\n            logger.info(f\"Output directory created: {output_dir}\")\n\n        # Save the image with text\n        logger.info(f\"Saving image with text to: {output_path}\")\n        cv2.imwrite(output_path, img)\n        logger.info(f\"Image with text saved successfully to: {output_path}\")\n\n        return output_path\n"
  },
  {
    "path": "src/imagesorcery_mcp/tools/fill.py",
    "content": "import os\nfrom typing import Annotated, Any, Dict, List, Optional\n\nimport cv2\nimport numpy as np\nfrom fastmcp import FastMCP\nfrom pydantic import Field\n\n# Import the central logger\nfrom imagesorcery_mcp.logging_config import logger\n\n\ndef register_tool(mcp: FastMCP):\n    @mcp.tool()\n    def fill(\n        input_path: Annotated[str, Field(description=\"Full path to the input image (must be a full path)\")],\n        areas: Annotated[\n            List[Dict[str, Any]],\n            Field(\n                description=(\n                    \"List of areas to fill. Each area can be a rectangle ({'x1', 'y1', 'x2', 'y2'}), \"\n                    \"a polygon ({'polygon': [[x,y],...]}), or a mask from a file ({'mask_path': 'path/to/mask.png'}). \"\n                    \"Optionally, include 'color' (list of 3 ints [B,G,R] or None, default black) and \"\n                    \"'opacity' (float 0.0-1.0, default 0.5) INSIDE each area object. \"\n                    \"Example: [{'polygon': [[0,0], [100,0], [100,100]], 'color': [255,0,0], 'opacity': 0.5}]\"\n                )\n            ),\n        ],\n        invert_areas: Annotated[\n            bool,\n            Field(\n                description=\"If True, fills everything EXCEPT the specified areas. Useful for background removal.\"\n            ),\n        ] = False,\n        output_path: Annotated[\n            Optional[str],\n            Field(\n                description=(\n                    \"Full path to save the output image (must be a full path). \"\n                    \"If not provided, will use input filename \"\n                    \"with '_filled' suffix.\"\n                )\n            ),\n        ] = None,\n    ) -> str:\n        \"\"\"\n        Fill specified areas of an image with a color and opacity.\n        \n        This tool allows filling multiple areas of an image with a customizable\n        color and opacity. Each area can be a rectangle, a polygon, or a mask from a PNG file.\n\n        The 'opacity' parameter controls the transparency of the fill. 1.0 is fully opaque,\n        0.0 is fully transparent. Default is 0.5.\n        The 'color' is in BGR format, e.g., [255, 0, 0] for blue. Default is black.\n\n        If the `color` is set to `None`, the specified area will be made fully transparent,\n        effectively deleting it (similar to ImageMagick). In this case, the `opacity`\n        parameter is ignored.\n        \n        If `invert_areas` is True, the tool will fill everything EXCEPT the specified areas.\n\n        Example usage:\n        {\n            \"input_path\": \"/path/to/image.jpg\",\n            \"areas\": [\n                {\n                    \"polygon\": [[0, 0], [100, 0], [100, 100], [0, 100]],\n                    \"color\": null,  // Makes area transparent\n                    \"opacity\": 1.0\n                }\n            ],\n            \"invert_areas\": true,  // Removes background, keeps only the polygon area\n            \"output_path\": \"/path/to/output.png\"\n        }\n        \n        Returns:\n            Path to the image with filled areas\n        \"\"\"\n        logger.info(f\"Fill tool requested for image: {input_path} with {len(areas)} areas, invert_areas={invert_areas}\")\n\n        # Check if input file exists\n        if not os.path.exists(input_path):\n            logger.error(f\"Input file not found: {input_path}\")\n            raise FileNotFoundError(f\"Input file not found: {input_path}. Please provide a full path to the file.\")\n\n        # Generate output path if not provided\n        if not output_path:\n            file_name, file_ext = os.path.splitext(input_path)\n            output_path = f\"{file_name}_filled{file_ext}\"\n            logger.info(f\"Output path not provided, generated: {output_path}\")\n\n        # Read the image using OpenCV\n        logger.info(f\"Reading image: {input_path}\")\n        img = cv2.imread(input_path, cv2.IMREAD_UNCHANGED)\n        if img is None:\n            logger.error(f\"Failed to read image: {input_path}\")\n            raise ValueError(f\"Failed to read image: {input_path}\")\n        logger.info(f\"Image read successfully. Shape: {img.shape}\")\n\n        # If any area requests transparency OR invert_areas is used with transparency, ensure we have an alpha channel\n        if any(area.get(\"color\") is None for area in areas) or (invert_areas and areas and areas[0].get(\"color\") is None):\n            if len(img.shape) < 3 or img.shape[2] == 3:\n                logger.info(\"Converting image to BGRA to support transparency\")\n                img = cv2.cvtColor(img, cv2.COLOR_BGR2BGRA if len(img.shape) > 2 and img.shape[2] == 3 else cv2.COLOR_GRAY2BGRA)\n\n        # Create mask for invert mode if needed\n        if invert_areas:\n            # Create a mask where specified areas are 0 (don't fill) and everything else is 255 (fill)\n            mask = np.ones(img.shape[:2], dtype=np.uint8) * 255\n            \n            # Mark each area as 0 (don't fill)\n            for area in areas:\n                if \"mask_path\" in area:\n                    if not os.path.exists(area[\"mask_path\"]):\n                        logger.warning(f\"Mask file not found: {area['mask_path']}. Skipping.\")\n                        continue\n                    area_mask = cv2.imread(area[\"mask_path\"], cv2.IMREAD_GRAYSCALE)\n                    if area_mask is None:\n                        logger.warning(f\"Failed to read mask file: {area['mask_path']}. Skipping.\")\n                        continue\n                    # Resize mask to match image dimensions if necessary\n                    if area_mask.shape != mask.shape:\n                        area_mask = cv2.resize(area_mask, (mask.shape[1], mask.shape[0]), interpolation=cv2.INTER_NEAREST)\n                    mask[area_mask > 0] = 0  # Set area to not fill\n                elif \"polygon\" in area:\n                    polygon_points = np.array(area[\"polygon\"], dtype=np.int32)\n                    cv2.fillPoly(mask, [polygon_points], 0)\n                elif \"x1\" in area and \"y1\" in area and \"x2\" in area and \"y2\" in area:\n                    x1, y1, x2, y2 = int(area[\"x1\"]), int(area[\"y1\"]), int(area[\"x2\"]), int(area[\"y2\"])\n                    mask[y1:y2, x1:x2] = 0\n            \n            # Get fill parameters from the first area\n            color = areas[0].get(\"color\") if areas else None\n            opacity = areas[0].get(\"opacity\", 0.5) if areas else 0.5\n            \n            logger.info(\"Inverted areas: applying fill to masked regions\")\n            \n            # Apply the fill using the mask\n            if color is None:\n                # Make masked areas fully transparent (black transparent)\n                if img.shape[2] != 4:\n                    raise ValueError(\"Image must have an alpha channel for transparency operations.\")\n                # Set all channels to 0 where mask is 255 (BGRA = 0,0,0,0)\n                img[mask == 255] = [0, 0, 0, 0]\n            else:\n                # Fill with color where mask is 255\n                color_tuple = tuple(color)\n                if not (0.0 <= opacity <= 1.0):\n                    logger.warning(f\"Opacity {opacity} is outside the valid range [0.0, 1.0]. Clamping it.\")\n                    opacity = max(0.0, min(1.0, opacity))\n                \n                # Create an overlay image\n                overlay = img.copy()\n                overlay[mask == 255] = color_tuple + (255,) if img.shape[2] == 4 else color_tuple\n                \n                # Blend the overlay with the original image\n                img = np.where(mask[:, :, None] == 255, \n                              cv2.addWeighted(overlay, opacity, img, 1 - opacity, 0),\n                              img)\n        else:\n            # Normal mode - process each area to fill\n            for i, area in enumerate(areas):\n                color = area.get(\"color\")\n\n                if color is None:\n                    # Make area transparent\n                    logger.debug(f\"Making area {i+1} transparent\")\n                    if img.shape[2] != 4:\n                        raise ValueError(\"Image must have an alpha channel for transparency operations.\")\n                    \n                    transparent_color = (0, 0, 0, 0)\n                    if \"mask_path\" in area:\n                        if not os.path.exists(area[\"mask_path\"]):\n                            logger.warning(f\"Mask file not found: {area['mask_path']}. Skipping.\")\n                            continue\n                        mask = cv2.imread(area[\"mask_path\"], cv2.IMREAD_GRAYSCALE)\n                        if mask is None:\n                            logger.warning(f\"Failed to read mask file: {area['mask_path']}. Skipping.\")\n                            continue\n                        if mask.shape != img.shape[:2]:\n                            mask = cv2.resize(mask, (img.shape[1], img.shape[0]), interpolation=cv2.INTER_NEAREST)\n                        img[mask > 0] = transparent_color\n                        logger.debug(f\"Mask area {i+1} from {area['mask_path']} made transparent\")\n                    elif \"polygon\" in area:\n                        polygon_points = np.array(area[\"polygon\"], dtype=np.int32)\n                        cv2.fillPoly(img, [polygon_points], transparent_color)\n                        logger.debug(f\"Polygon area {i+1} made transparent\")\n                    elif \"x1\" in area and \"y1\" in area and \"x2\" in area and \"y2\" in area:\n                        x1, y1, x2, y2 = int(area[\"x1\"]), int(area[\"y1\"]), int(area[\"x2\"]), int(area[\"y2\"])\n                        img[y1:y2, x1:x2] = transparent_color\n                        logger.debug(f\"Rectangle area {i+1} made transparent\")\n                    else:\n                        logger.warning(f\"Skipping area {i+1} due to missing 'polygon', 'mask_path' or 'x1,y1,x2,y2' keys.\")\n                else:\n                    # Fill with color\n                    color_tuple = tuple(color)\n                    opacity = area.get(\"opacity\", 0.5)\n\n                    if not (0.0 <= opacity <= 1.0):\n                        logger.warning(f\"Opacity {opacity} is outside the valid range [0.0, 1.0]. Clamping it.\")\n                        opacity = max(0.0, min(1.0, opacity))\n\n                    mask_to_fill = None\n                    if \"mask_path\" in area:\n                        if not os.path.exists(area[\"mask_path\"]):\n                            logger.warning(f\"Mask file not found: {area['mask_path']}. Skipping.\")\n                            continue\n                        mask_to_fill = cv2.imread(area[\"mask_path\"], cv2.IMREAD_GRAYSCALE)\n                        if mask_to_fill is None:\n                            logger.warning(f\"Failed to read mask file: {area['mask_path']}. Skipping.\")\n                            continue\n                        if mask_to_fill.shape != img.shape[:2]:\n                            mask_to_fill = cv2.resize(mask_to_fill, (img.shape[1], img.shape[0]), interpolation=cv2.INTER_NEAREST)\n                        logger.debug(f\"Filling mask area {i+1} from {area['mask_path']} with color={color_tuple}, opacity={opacity}\")\n                    elif \"polygon\" in area:\n                        logger.debug(f\"Filling polygon area {i+1} with color={color_tuple}, opacity={opacity}\")\n                        polygon_points = np.array(area[\"polygon\"], dtype=np.int32)\n                        mask_to_fill = np.zeros(img.shape[:2], dtype=np.uint8)\n                        cv2.fillPoly(mask_to_fill, [polygon_points], 255)\n                    elif \"x1\" in area and \"y1\" in area and \"x2\" in area and \"y2\" in area:\n                        x1, y1, x2, y2 = int(area[\"x1\"]), int(area[\"y1\"]), int(area[\"x2\"]), int(area[\"y2\"])\n                        logger.debug(f\"Filling rectangle area {i+1}: ({x1}, {y1}) to ({x2}, {y2}) with color={color_tuple}, opacity={opacity}\")\n                        mask_to_fill = np.zeros(img.shape[:2], dtype=np.uint8)\n                        cv2.rectangle(mask_to_fill, (x1, y1), (x2, y2), 255, -1)\n\n                    if mask_to_fill is not None:\n                        # Create an overlay for the fill color\n                        overlay = img.copy()\n                        # Apply color to the overlay where the mask is set\n                        overlay[mask_to_fill > 0] = color_tuple + (255,) if img.shape[2] == 4 else color_tuple\n                        \n                        # Blend the overlay with the original image using the mask\n                        img = np.where(\n                            mask_to_fill[:, :, None] > 0,  # Condition where to apply the blend\n                            cv2.addWeighted(overlay, opacity, img, 1 - opacity, 0),\n                            img\n                        )\n                        logger.debug(f\"Area {i+1} filled\")\n                    else:\n                        logger.warning(f\"Skipping area {i+1} due to missing 'polygon', 'mask_path' or 'x1,y1,x2,y2' keys.\")\n\n        output_dir = os.path.dirname(output_path)\n        if output_dir and not os.path.exists(output_dir):\n            os.makedirs(output_dir)\n\n        logger.info(f\"Saving filled image to: {output_path}\")\n        cv2.imwrite(output_path, img)\n        logger.info(f\"Filled image saved successfully to: {output_path}\")\n\n        return output_path\n"
  },
  {
    "path": "src/imagesorcery_mcp/tools/find.py",
    "content": "import os\nfrom pathlib import Path\nfrom typing import Annotated, Any, Dict, List, Literal, Optional, Union\n\nimport cv2\nimport numpy as np\nfrom fastmcp import FastMCP\nfrom pydantic import Field\n\n# Import the central logger and config\nfrom imagesorcery_mcp.config import get_config\nfrom imagesorcery_mcp.logging_config import logger\n\n\ndef get_model_path(model_name):\n    \"\"\"Get the path to a model in the models directory.\"\"\"\n    logger.info(f\"Attempting to get path for model: {model_name}\")\n    model_path = Path(\"models\") / model_name\n    if model_path.exists():\n        logger.info(f\"Model found at: {model_path}\")\n        return str(model_path)\n    logger.warning(f\"Model not found in models directory: {model_name}\")\n    return None\n\n\ndef check_clip_installed():\n    \"\"\"Check if CLIP is installed and the model is available.\"\"\"\n    logger.info(\"Checking if CLIP is installed and MobileCLIP model is available\")\n    try:\n        import clip  # noqa: F401\n        logger.info(\"CLIP is installed\")\n        \n        # Check if the MobileCLIP model is available in the root directory\n        clip_model_path = Path(\"mobileclip_blt.ts\")\n        if clip_model_path.exists():\n            logger.info(f\"MobileCLIP model found at: {clip_model_path}\")\n            return True, None\n        \n        logger.warning(f\"MobileCLIP model not found at: {clip_model_path}\")\n        return False, \"MobileCLIP model not found. Please run 'download-clip-models' to download it.\"\n    except ImportError:\n        logger.warning(\"CLIP is not installed\")\n        return False, \"CLIP is not installed. Please install it with 'pip install git+https://github.com/ultralytics/CLIP.git'\"\n\n\ndef register_tool(mcp: FastMCP):\n    @mcp.tool()\n    def find(\n        input_path: Annotated[str, Field(description=\"Full path to the input image (must be a full path)\")],\n        description: Annotated[\n            str, Field(description=\"Text description of the object to find\")\n        ],\n        confidence: Annotated[\n            Optional[float],\n            Field(\n                description=\"Confidence threshold for detection (0.0 to 1.0). If not provided, uses config default.\",\n                ge=0.0,\n                le=1.0,\n            ),\n        ] = None,\n        model_name: Annotated[\n            Optional[str],\n            Field(\n                description=\"Model name to use for finding objects (must support text prompts). If not provided, uses config default.\",\n            ),\n        ] = None,\n        return_all_matches: Annotated[\n            bool, Field(description=\"If True, returns all matching objects; if False, returns only the best match\")\n        ] = False,\n        return_geometry: Annotated[\n            bool, Field(description=\"If True, returns segmentation masks or polygons for found objects.\")\n        ] = False,\n        geometry_format: Annotated[\n            Literal[\"mask\", \"polygon\"], Field(description=\"Format for returned geometry: 'mask' or 'polygon'.\")\n        ] = \"mask\",\n    ) -> Dict[str, Union[str, List[Dict[str, Any]], bool]]:\n        \"\"\"\n        Find objects in an image based on a text description.\n        \n        This tool uses open-vocabulary detection models to find objects matching a text description.\n        It requires pre-downloaded YOLOE models that support text prompts (e.g. yoloe-11l-seg.pt).\n\n        This tool can optionally return segmentation masks or polygons.\n\n        When 'mask' is chosen for geometry_format, a PNG file is created for each\n        found object's mask. The file path is returned in the 'mask_path' field.\n\n        Returns:\n            Dictionary containing the input image path and a list of found objects.\n            Each object includes its confidence score and bounding box. If return_geometry\n            is True, it also includes a 'mask_path' (path to a PNG file) or\n            'polygon' (list of points).\n        \"\"\"\n        # Get configuration defaults\n        config = get_config()\n\n        # Use config defaults if parameters not provided\n        if confidence is None:\n            confidence = config.find.confidence_threshold\n            logger.info(f\"Using config default confidence: {confidence}\")\n\n        if model_name is None:\n            model_name = config.find.default_model\n            logger.info(f\"Using config default model: {model_name}\")\n\n        logger.info(\n            f\"Find tool requested for image: {input_path}, description: '{description}', model: {model_name}, \"\n            f\"confidence: {confidence}, return_all_matches: {return_all_matches}, \"\n            f\"return_geometry: {return_geometry}, geometry_format: {geometry_format}\"\n        )\n\n        # Check if input file exists\n        if not os.path.exists(input_path):\n            logger.error(f\"Input file not found: {input_path}\")\n            raise FileNotFoundError(f\"Input file not found: {input_path}. Please provide a full path to the file.\")\n\n        # Add .pt extension if it doesn't exist\n        if not model_name.endswith(\".pt\"):\n            original_model_name = model_name\n            model_name = f\"{model_name}.pt\"\n            logger.info(f\"Added .pt extension to model name: {original_model_name} -> {model_name}\")\n\n        # Try to find the model\n        model_path = get_model_path(model_name)\n        logger.info(f\"Resolved model path: {model_path}\")\n\n        # If model not found, raise an error with helpful message\n        if not model_path:\n            logger.error(f\"Model {model_name} not found.\")\n            # List available models\n            available_models = []\n            models_dir = Path(\"models\")\n            \n            # Find all .pt files in the models directory and its subdirectories\n            if models_dir.exists():\n                for file in models_dir.glob(\"**/*.pt\"):\n                    available_models.append(str(file.relative_to(models_dir)))\n\n            # Filter for models that support text prompts\n            text_prompt_models = [\n                model for model in available_models \n                if \"yoloe\" in model.lower() and not model.lower().endswith(\"-pf.pt\")\n            ]\n\n            error_msg = (\n                f\"Model {model_name} not found. \"\n                f\"Available models supporting text prompts: \"\n                f\"{', '.join(text_prompt_models) if text_prompt_models else 'None'}\\n\"\n                \"To use this tool, you need to download a model that supports text prompts first using:\\n\"\n                \"download-yolo-models --ultralytics MODEL_NAME\\n\"\n                \"Recommended models: yoloe-11l-seg.pt\\n\"\n                \"Models will be downloaded to the 'models' directory \"\n                \"in the project root.\"\n            )\n            raise RuntimeError(error_msg)\n\n        # Check if the model supports text prompts\n        if not (\"yoloe\" in model_name.lower() and not model_name.lower().endswith(\"-pf.pt\")):\n            logger.error(f\"Model {model_name} does not support text prompts.\")\n            raise ValueError(\n                f\"The model {model_name} does not support text prompts. \"\n                f\"Please use a model that supports text prompts, such as \"\n                f\"yoloe-11l-seg.pt\"\n            )\n            \n        # Check if CLIP is installed and the model is available\n        clip_installed, clip_error = check_clip_installed()\n        if not clip_installed:\n            logger.error(f\"CLIP not installed or MobileCLIP model missing: {clip_error}\")\n            raise RuntimeError(\n                f\"Cannot use text prompts: {clip_error}\\n\"\n                \"Text prompts require CLIP and the MobileCLIP model.\\n\"\n                \"Run 'download-clip-models' to set up the required dependencies.\"\n            )\n\n        try:\n            # Set environment variable to use the models directory\n            os.environ[\"YOLO_CONFIG_DIR\"] = str(Path(\"models\").absolute())\n            logger.info(f\"Set YOLO_CONFIG_DIR environment variable to: {os.environ['YOLO_CONFIG_DIR']}\")\n            \n            # Set environment variable for CLIP model path\n            clip_model_path = Path(\"mobileclip_blt.ts\").absolute()\n            if not clip_model_path.exists():\n                 logger.error(f\"CLIP model not found at expected path: {clip_model_path}\")\n                 raise RuntimeError(\n                    f\"CLIP model not found at {clip_model_path}. \"\n                    \"Please run 'download-clip-models' to download it.\"\n                )\n            os.environ[\"CLIP_MODEL_PATH\"] = str(clip_model_path)\n            logger.info(f\"Set CLIP_MODEL_PATH environment variable to: {os.environ['CLIP_MODEL_PATH']}\")\n            \n            logger.info(\"Importing Ultralytics\")\n            # Import here to avoid loading ultralytics if not needed\n            from ultralytics import YOLO\n            logger.info(\"Ultralytics imported successfully\")\n\n            logger.info(\"Loading model...\")\n            \n            # Load the model from the found path\n            model = YOLO(model_path)\n            logger.info(\"Model loaded successfully\")\n            \n            # For YOLOe models, we need to set the classes using the text description\n            logger.info(\"Setting up text prompts...\")\n            \n            # Convert the description to a list (YOLOe expects a list of class names)\n            class_names = [description]\n            logger.debug(f\"Class names for text prompts: {class_names}\")\n            \n            try:\n                # Set the classes for the model\n                logger.info(\"Getting text embeddings...\")\n                text_embeddings = model.get_text_pe(class_names)\n                logger.info(\"Setting classes...\")\n                model.set_classes(class_names, text_embeddings)\n                logger.info(\"Classes set successfully\")\n            except Exception as e:\n                logger.error(f\"Error setting classes: {str(e)}\", exc_info=True)\n                raise RuntimeError(\n                    f\"Error setting up text prompts: {str(e)}\\n\"\n                    \"This may be due to missing CLIP dependencies.\\n\"\n                    \"Please run 'download-clip-models' to set up the required dependencies.\"\n                ) from e\n            \n            # Run inference on the image\n            logger.info(f\"Running inference on {input_path} with confidence {confidence}\")\n            results = model.predict(input_path, conf=confidence, verbose=True)\n            logger.info(\"Inference completed\")\n            \n            found_objects = []\n            \n            # Process results\n            if results and len(results) > 0:\n                logger.info(f\"Processing {len(results)} results\")\n                \n                # The main result object is the first one in the list\n                main_result = results[0]\n\n                if return_geometry and main_result.masks is None:\n                    raise ValueError(\n                        f\"Model '{model_name}' does not support segmentation, but return_geometry=True was requested. \"\n                        \"Please use a segmentation model (e.g., one ending in '-seg.pt').\"\n                    )\n\n                if hasattr(main_result, 'boxes') and len(main_result.boxes) > 0:\n                    logger.info(f\"Found {len(main_result.boxes)} boxes\")\n                    \n                    for i, box in enumerate(main_result.boxes):\n                        # Get class name\n                        class_id = int(box.cls.item())\n                        class_name = main_result.names[class_id]\n                        \n                        # Get confidence score\n                        conf = float(box.conf.item())\n                        \n                        # Get bounding box coordinates (x1, y1, x2, y2)\n                        x1, y1, x2, y2 = [float(coord) for coord in box.xyxy[0].tolist()]\n                        \n                        found_object = {\n                            \"description\": description,\n                            \"match\": class_name,\n                            \"confidence\": conf,\n                            \"bbox\": [x1, y1, x2, y2]\n                        }\n\n                        if return_geometry:\n                            if geometry_format == \"mask\":\n                                # Convert mask to a savable format\n                                mask = main_result.masks.data[i].cpu().numpy()\n                                mask_image = (mask * 255).astype(np.uint8)\n\n                                # Generate a unique filename for the mask, always with .png extension\n                                input_p = Path(input_path)\n                                base_name = input_p.stem\n                                output_dir = input_p.parent\n                                mask_output_path = output_dir / f\"{base_name}_mask_{i}.png\"\n\n                                # Save the mask as a PNG file\n                                try:\n                                    success = cv2.imwrite(str(mask_output_path), mask_image)\n                                    if success:\n                                        logger.info(f\"Saved find mask to {mask_output_path}\")\n                                        found_object[\"mask_path\"] = str(mask_output_path)\n                                    else:\n                                        logger.error(f\"Failed to save mask to {mask_output_path}\")\n                                except Exception as e:\n                                    logger.error(f\"An unexpected error occurred while saving mask to {mask_output_path}: {e}\")\n                            elif geometry_format == \"polygon\":\n                                polygon = main_result.masks.xy[i].tolist()\n                                found_object[\"polygon\"] = polygon\n\n                        found_objects.append(found_object)\n                        logger.debug(\n                            f\"Found object: match={class_name}, confidence={conf:.2f}, bbox=[{x1:.2f}, {y1:.2f}, {x2:.2f}, {y2:.2f}]\")\n                else:\n                    logger.info(\"No boxes found in results\")\n            else:\n                logger.info(\"No results returned from model\")\n            \n            # Sort by confidence (highest first)\n            found_objects.sort(key=lambda x: x[\"confidence\"], reverse=True)\n            \n            # Return only the best match if return_all_matches is False and we have matches\n            if not return_all_matches and found_objects:\n                logger.info(\"Returning only the best match\")\n                found_objects = [found_objects[0]]\n            \n            logger.info(f\"Returning {len(found_objects)} found objects\")\n            \n            return {\n                \"image_path\": input_path,\n                \"query\": description,\n                \"found_objects\": found_objects,\n                \"found\": len(found_objects) > 0\n            }\n\n        except Exception as e:\n            logger.error(f\"Error in find tool: {str(e)}\", exc_info=True)\n            \n            # Provide more helpful error message\n            error_msg = f\"Error finding objects: {str(e)}\\n\"\n\n            if \"not found\" in str(e).lower():\n                error_msg += (\n                    \"The model could not be found. \"\n                    \"Please download it first using: \"\n                    \"download-yolo-models --ultralytics MODEL_NAME\"\n                )\n            elif \"permission denied\" in str(e).lower():\n                error_msg += (\n                    \"Permission denied when trying to access or create the models \"\n                    \"directory.\\n\"\n                    \"Try running the command with appropriate permissions.\"\n                )\n            elif \"no module named\" in str(e).lower():\n                error_msg += (\n                    \"Required dependencies are missing. \"\n                    \"Please install them using: \"\n                    \"pip install git+https://github.com/ultralytics/CLIP.git\"\n                )\n            elif \"mobileclip\" in str(e).lower():\n                error_msg += (\n                    \"MobileCLIP model is missing. \"\n                    \"Please download it using: \"\n                    \"download-clip-models\"\n                )\n\n            raise RuntimeError(error_msg) from e\n"
  },
  {
    "path": "src/imagesorcery_mcp/tools/metainfo.py",
    "content": "import datetime\nimport os\nfrom typing import Annotated, Any\n\nfrom fastmcp import FastMCP\nfrom PIL import Image\nfrom pydantic import Field\n\n# Import the central logger\nfrom imagesorcery_mcp.logging_config import logger\n\n\ndef register_tool(mcp: FastMCP):\n    @mcp.tool()\n    def get_metainfo(\n        input_path: Annotated[str, Field(description=\"Full path to the input image (must be a full path)\")],\n    ) -> Any:\n        \"\"\"\n        Get metadata information about an image file.\n\n        Returns:\n            Dictionary containing metadata about the image (size, dimensions,\n            format, etc.)\n        \"\"\"\n        logger.info(f\"Get metainfo tool requested for image: {input_path}\")\n\n        # Check if input_path is empty\n        if not input_path or not input_path.strip():\n            logger.error(\"Input path is empty. Please provide a full path to the image file.\")\n            raise ValueError(\"input_path cannot be empty. Please provide a full path to the image file.\")\n\n        # Check if input file exists\n        if not os.path.exists(input_path):\n            logger.error(f\"Input file not found: {input_path}\")\n            raise FileNotFoundError(f\"Input file not found: {input_path}. Please provide a full path to the file.\")\n        logger.info(f\"Input file found: {input_path}\")\n\n        # Get file stats\n        logger.info(f\"Getting file stats for: {input_path}\")\n        file_stats = os.stat(input_path)\n        file_size = file_stats.st_size\n        creation_time = datetime.datetime.fromtimestamp(file_stats.st_ctime)\n        modification_time = datetime.datetime.fromtimestamp(file_stats.st_mtime)\n        logger.info(f\"File size: {file_size} bytes, Created: {creation_time}, Modified: {modification_time}\")\n\n        # Get image-specific information\n        logger.info(f\"Opening image with PIL: {input_path}\")\n        try:\n            with Image.open(input_path) as img:\n                width, height = img.size\n                format = img.format\n                mode = img.mode\n\n                logger.info(f\"Image opened successfully. Dimensions: {width}x{height}, Format: {format}, Mode: {mode}\")\n        except Exception as e:\n            logger.error(f\"Failed to open image with PIL: {input_path} - {str(e)}\")\n            raise ValueError(f\"Failed to read image: {input_path}\") from e\n\n\n        # Compile all metadata\n        metadata = {\n            \"filename\": os.path.basename(input_path),\n            \"path\": input_path,\n            \"size_bytes\": file_size,\n            \"size_kb\": round(file_size / 1024, 2),\n            \"size_mb\": round(file_size / (1024 * 1024), 2),\n            \"dimensions\": {\n                \"width\": width,\n                \"height\": height,\n                \"aspect_ratio\": round(width / height, 2) if height != 0 else None,\n            },\n            \"format\": format,\n            \"color_mode\": mode,\n            \"created_at\": creation_time.isoformat(),\n            \"modified_at\": modification_time.isoformat(),\n        }\n        logger.info(\"Metadata compiled successfully\")\n        logger.debug(f\"Metadata: {metadata}\")\n\n        return metadata\n"
  },
  {
    "path": "src/imagesorcery_mcp/tools/ocr.py",
    "content": "import os\nfrom typing import Annotated, Dict, List, Optional, Union\n\nfrom fastmcp import FastMCP\nfrom pydantic import Field\n\n# Import the central logger and config\nfrom imagesorcery_mcp.config import get_config\nfrom imagesorcery_mcp.logging_config import logger\n\n\ndef register_tool(mcp: FastMCP):\n    @mcp.tool()\n    def ocr(\n        input_path: Annotated[str, Field(description=\"Full path to the input image (must be a full path)\")],\n        language: Annotated[\n            Optional[str],\n            Field(\n                description=\"Language code for OCR (e.g., 'en', 'ru', 'fr', etc.). If not provided, uses config default.\",\n            ),\n        ] = None,\n    ) -> Dict[str, Union[str, List[Dict[str, Union[str, float, List[float]]]]]]:\n        \"\"\"\n        Performs Optical Character Recognition (OCR) on an image using EasyOCR.\n\n        This tool extracts text from images in various languages. The default language is English,\n        but you can specify other languages using their language codes (e.g., 'en', 'ru', 'fr', etc.).\n\n        Returns:\n            Dictionary containing the input image path and a list of detected text segments\n            with their text content, confidence scores, and bounding box coordinates.\n        \"\"\"\n        # Get configuration defaults\n        config = get_config()\n\n        # Use config default if language not provided\n        if language is None:\n            language = config.ocr.language\n            logger.info(f\"Using config default language: {language}\")\n\n        logger.info(f\"OCR requested for image: {input_path} with language: {language}\")\n\n        # Check if input file exists\n        if not os.path.exists(input_path):\n            logger.error(f\"Input file not found: {input_path}\")\n            raise FileNotFoundError(f\"Input file not found: {input_path}. Please provide a full path to the file.\")\n\n        try:\n            # Import here to avoid loading dependencies if not needed\n            import cv2\n            import easyocr\n            \n            logger.info(\"EasyOCR imported successfully\")\n\n            # Read the image\n            logger.info(f\"Reading image from: {input_path}\")\n            img = cv2.imread(input_path)\n            if img is None:\n                logger.error(f\"Failed to read image: {input_path}\")\n                raise ValueError(f\"Failed to read image: {input_path}. The file may be corrupted or not an image.\")\n            \n            # Check image dimensions and convert to grayscale\n            logger.info(f\"Image shape: {img.shape}\")\n            if len(img.shape) == 3:\n                img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)\n                logger.info(f\"Converted image to grayscale: {img_gray.shape}\")\n            else:\n                img_gray = img\n                logger.info(\"Image is already grayscale\")\n\n            # Create reader with specified language\n            logger.info(f\"Creating EasyOCR reader for language: {language}\")\n            reader = easyocr.Reader([language])\n            logger.info(\"EasyOCR reader created successfully\")\n\n            # Perform OCR directly on the numpy array\n            logger.info(\"Starting OCR processing on image array\")\n            results = reader.readtext(img_gray)  # Pass the numpy array directly\n            logger.info(f\"OCR processing completed with {len(results)} text segments found\")\n\n            # Process results\n            text_segments = []\n            for i, result in enumerate(results):\n                # EasyOCR can return results in different formats depending on the version\n                # Handle both possible formats\n                if len(result) == 3:\n                    # Format: (bbox, text, confidence)\n                    bbox, text, confidence = result\n                elif len(result) == 4:\n                    # Format: (bbox, text, confidence, _)\n                    bbox, text, confidence, _ = result\n                else:\n                    # Unknown format, try to extract what we can\n                    logger.warning(f\"Unexpected result format for segment {i}: {result}\")\n                    bbox = result[0] if len(result) > 0 else [[0, 0], [0, 0], [0, 0], [0, 0]]\n                    text = result[1] if len(result) > 1 else \"\"\n                    confidence = result[2] if len(result) > 2 else 0.0\n\n                # EasyOCR returns bounding box as 4 points (top-left, top-right, bottom-right, bottom-left)\n                # Convert to [x1, y1, x2, y2] format (top-left and bottom-right corners)\n                x_coords = [point[0] for point in bbox]\n                y_coords = [point[1] for point in bbox]\n                x1, y1 = min(x_coords), min(y_coords)\n                x2, y2 = max(x_coords), max(y_coords)\n\n                text_segments.append(\n                    {\n                        \"text\": text,\n                        \"confidence\": float(confidence),\n                        \"bbox\": [float(x1), float(y1), float(x2), float(y2)]\n                    }\n                )\n                logger.debug(f\"Processed segment {i}: text='{text[:30]}...' confidence={confidence:.2f}\")\n\n            logger.info(f\"OCR completed successfully for {input_path}\")\n            return {\"image_path\": input_path, \"text_segments\": text_segments}\n\n        except ImportError as e:\n            if \"easyocr\" in str(e).lower():\n                error_msg = (\n                    \"EasyOCR is not installed. \"\n                    \"Please install it first using: \"\n                    \"pip install easyocr\"\n                )\n            elif \"cv2\" in str(e).lower():\n                error_msg = (\n                    \"OpenCV (cv2) is not installed. \"\n                    \"Please install it first using: \"\n                    \"pip install opencv-python\"\n                )\n            else:\n                error_msg = f\"Required dependency not installed: {str(e)}\"\n            \n            logger.error(f\"Import error: {error_msg}\")\n            raise RuntimeError(error_msg) from None\n        except Exception as e:\n            # Provide more helpful error message\n            error_msg = f\"Error performing OCR: {str(e)}\\n\"\n\n            if \"not found\" in str(e).lower() and \"language\" in str(e).lower():\n                error_msg += (\n                    f\"The language '{language}' is not supported or the language model \"\n                    f\"could not be found. Please check available languages in EasyOCR documentation.\"\n                )\n                logger.error(f\"Language not supported: {language}\")\n            elif \"permission denied\" in str(e).lower():\n                error_msg += (\n                    \"Permission denied when trying to access the image file.\\n\"\n                    \"Try running the command with appropriate permissions.\"\n                )\n                logger.error(f\"Permission denied accessing file: {input_path}\")\n            else:\n                logger.error(f\"OCR processing error: {str(e)}\", exc_info=True)\n\n            raise RuntimeError(error_msg) from e\n"
  },
  {
    "path": "src/imagesorcery_mcp/tools/overlay.py",
    "content": "import os\nfrom typing import Annotated, Optional\n\nimport cv2\nimport numpy as np\nfrom fastmcp import FastMCP\nfrom pydantic import Field\n\n# Import the central logger\nfrom imagesorcery_mcp.logging_config import logger\n\n\ndef register_tool(mcp: FastMCP):\n    @mcp.tool()\n    def overlay(\n        base_image_path: Annotated[str, Field(description=\"Full path to the base image (must be a full path)\")],\n        overlay_image_path: Annotated[\n            str, Field(description=\"Full path to the overlay image (must be a full path). This image can have transparency.\")\n        ],\n        x: Annotated[int, Field(description=\"X-coordinate of the top-left corner of the overlay image on the base image.\")],\n        y: Annotated[int, Field(description=\"Y-coordinate of the top-left corner of the overlay image on the base image.\")],\n        output_path: Annotated[\n            Optional[str],\n            Field(\n                description=(\n                    \"Full path to save the output image (must be a full path). \"\n                    \"If not provided, will use the base image filename \"\n                    \"with '_overlaid' suffix.\"\n                )\n            ),\n        ] = None,\n    ) -> str:\n        \"\"\"\n        Overlays one image on top of another, handling transparency.\n\n        This tool places an overlay image onto a base image at a specified (x, y)\n        coordinate. If the overlay image has an alpha channel (e.g., a transparent PNG),\n        it will be blended correctly with the base image. If the overlay extends\n        beyond the boundaries of the base image, it will be cropped.\n\n        Returns:\n            Path to the resulting image.\n        \"\"\"\n        logger.info(f\"Overlay tool requested for base image: {base_image_path}, overlay image: {overlay_image_path}\")\n\n        # Check if input files exist\n        if not os.path.exists(base_image_path):\n            logger.error(f\"Base image not found: {base_image_path}\")\n            raise FileNotFoundError(f\"Base image not found: {base_image_path}. Please provide a full path to the file.\")\n        if not os.path.exists(overlay_image_path):\n            logger.error(f\"Overlay image not found: {overlay_image_path}\")\n            raise FileNotFoundError(f\"Overlay image not found: {overlay_image_path}. Please provide a full path to the file.\")\n\n        # Generate output path if not provided\n        if not output_path:\n            file_name, file_ext = os.path.splitext(base_image_path)\n            output_path = f\"{file_name}_overlaid{file_ext}\"\n            logger.info(f\"Output path not provided, generated: {output_path}\")\n\n        # Read images\n        base_img = cv2.imread(base_image_path)\n        overlay_img = cv2.imread(overlay_image_path, cv2.IMREAD_UNCHANGED)\n\n        if base_img is None:\n            logger.error(f\"Failed to read base image: {base_image_path}\")\n            raise ValueError(f\"Failed to read base image: {base_image_path}\")\n        if overlay_img is None:\n            logger.error(f\"Failed to read overlay image: {overlay_image_path}\")\n            raise ValueError(f\"Failed to read overlay image: {overlay_image_path}\")\n\n        # Get dimensions\n        base_h, base_w, _ = base_img.shape\n        overlay_h, overlay_w, _ = overlay_img.shape\n\n        # Handle coordinates and potential cropping of the overlay\n        x_start, y_start = x, y\n        x_end, y_end = x + overlay_w, y + overlay_h\n        overlay_x_start, overlay_y_start = 0, 0\n        overlay_x_end, overlay_y_end = overlay_w, overlay_h\n\n        if x_start < 0:\n            overlay_x_start = -x_start\n            x_start = 0\n        if y_start < 0:\n            overlay_y_start = -y_start\n            y_start = 0\n        if x_end > base_w:\n            overlay_x_end -= x_end - base_w\n            x_end = base_w\n        if y_end > base_h:\n            overlay_y_end -= y_end - base_h\n            y_end = base_h\n\n        if x_start >= x_end or y_start >= y_end:\n            logger.warning(\"Overlay is completely outside the base image. No changes made.\")\n            cv2.imwrite(output_path, base_img)\n            return output_path\n\n        overlay_img = overlay_img[overlay_y_start:overlay_y_end, overlay_x_start:overlay_x_end]\n        roi = base_img[y_start:y_end, x_start:x_end]\n\n        if overlay_img.shape[2] == 4:\n            logger.info(\"Overlay image has alpha channel. Performing alpha blending.\")\n            alpha = overlay_img[:, :, 3] / 255.0\n            overlay_colors = overlay_img[:, :, :3]\n            alpha_mask = cv2.merge([alpha, alpha, alpha])\n            blended_roi = (alpha_mask * overlay_colors) + ((1 - alpha_mask) * roi)\n            base_img[y_start:y_end, x_start:x_end] = blended_roi.astype(np.uint8)\n        else:\n            logger.info(\"Overlay image has no alpha channel. Pasting directly.\")\n            base_img[y_start:y_end, x_start:x_end] = overlay_img\n\n        output_dir = os.path.dirname(output_path)\n        if output_dir and not os.path.exists(output_dir):\n            os.makedirs(output_dir)\n\n        cv2.imwrite(output_path, base_img)\n        logger.info(f\"Overlaid image saved successfully to: {output_path}\")\n\n        return output_path\n"
  },
  {
    "path": "src/imagesorcery_mcp/tools/resize.py",
    "content": "import os\nfrom typing import Annotated, Optional\n\nimport cv2\nfrom fastmcp import FastMCP\nfrom pydantic import Field\n\n# Import the central logger and config\nfrom imagesorcery_mcp.config import get_config\nfrom imagesorcery_mcp.logging_config import logger\n\n\ndef register_tool(mcp: FastMCP):\n    @mcp.tool()\n    def resize(\n        input_path: Annotated[str, Field(description=\"Full path to the input image (must be a full path)\")],\n        width: Annotated[\n            Optional[int],\n            Field(\n                description=(\n                    \"Target width in pixels. \"\n                    \"If None, will be calculated based on height \"\n                    \"and preserve aspect ratio\"\n                )\n            ),\n        ] = None,\n        height: Annotated[\n            Optional[int],\n            Field(\n                description=(\n                    \"Target height in pixels. \"\n                    \"If None, will be calculated based on width \"\n                    \"and preserve aspect ratio\"\n                )\n            ),\n        ] = None,\n        scale_factor: Annotated[\n            Optional[float],\n            Field(\n                description=(\n                    \"Scale factor to resize the image \"\n                    \"(e.g., 0.5 for half size, 2.0 for double size). \"\n                    \"Overrides width and height if provided\"\n                )\n            ),\n        ] = None,\n        interpolation: Annotated[\n            Optional[str],\n            Field(\n                description=(\n                    \"Interpolation method: 'nearest', 'linear', 'area', \"\n                    \"'cubic', 'lanczos'. If not provided, uses config default.\"\n                )\n            ),\n        ] = None,\n        output_path: Annotated[\n            str,\n            Field(\n                description=(\n                    \"Full path to save the output image (must be a full path). \"\n                    \"If not provided, will use input filename \"\n                    \"with '_resized' suffix.\"\n                )\n            ),\n        ] = None,\n    ) -> str:\n        \"\"\"\n        Resize an image using OpenCV.\n\n        The function can resize an image in three ways:\n        1. By specifying both width and height\n        2. By specifying either width or height (preserving aspect ratio)\n        3. By specifying a scale factor\n\n        Returns:\n            Path to the resized image\n        \"\"\"\n        # Get configuration defaults\n        config = get_config()\n\n        # Use config default if interpolation not provided\n        if interpolation is None:\n            interpolation = config.resize.interpolation\n            logger.info(f\"Using config default interpolation: {interpolation}\")\n\n        logger.info(f\"Resize tool requested for image: {input_path}, width: {width}, height: {height}, scale_factor: {scale_factor}, interpolation: {interpolation}\")\n\n        # Check if input file exists\n        if not os.path.exists(input_path):\n            logger.error(f\"Input file not found: {input_path}\")\n            raise FileNotFoundError(f\"Input file not found: {input_path}. Please provide a full path to the file.\")\n        logger.info(f\"Input file found: {input_path}\")\n\n        # Generate output path if not provided\n        if not output_path:\n            file_name, file_ext = os.path.splitext(input_path)\n            output_path = f\"{file_name}_resized{file_ext}\"\n            logger.info(f\"Output path not provided, generated: {output_path}\")\n\n        # Read the image using OpenCV\n        logger.info(f\"Reading image: {input_path}\")\n        img = cv2.imread(input_path)\n        if img is None:\n            logger.error(f\"Failed to read image: {input_path}\")\n            raise ValueError(f\"Failed to read image: {input_path}\")\n        logger.info(f\"Image read successfully. Shape: {img.shape}\")\n\n        # Get original dimensions\n        orig_height, orig_width = img.shape[:2]\n        logger.debug(f\"Original dimensions: {orig_width}x{orig_height}\")\n\n        # Determine interpolation method\n        interpolation_methods = {\n            \"nearest\": cv2.INTER_NEAREST,\n            \"linear\": cv2.INTER_LINEAR,\n            \"area\": cv2.INTER_AREA,\n            \"cubic\": cv2.INTER_CUBIC,\n            \"lanczos\": cv2.INTER_LANCZOS4,\n        }\n\n        if interpolation not in interpolation_methods:\n            logger.error(f\"Invalid interpolation method: {interpolation}\")\n            raise ValueError(\n                f\"Invalid interpolation method. Choose from: \"\n                f\"{', '.join(interpolation_methods.keys())}\"\n            )\n\n        interp = interpolation_methods[interpolation]\n        logger.debug(f\"Using interpolation method: {interpolation} ({interp})\")\n\n        # Calculate target dimensions\n        if scale_factor is not None:\n            # Resize by scale factor\n            target_width = int(orig_width * scale_factor)\n            target_height = int(orig_height * scale_factor)\n            logger.info(f\"Resizing by scale factor {scale_factor} to {target_width}x{target_height}\")\n        elif width is not None and height is not None:\n            # Resize to specific dimensions\n            target_width = width\n            target_height = height\n            logger.info(f\"Resizing to specific dimensions: {target_width}x{target_height}\")\n        elif width is not None:\n            # Resize to specific width, maintain aspect ratio\n            target_width = width\n            target_height = int(orig_height * (width / orig_width))\n            logger.info(f\"Resizing to width {width}, maintaining aspect ratio. Target height: {target_height}\")\n        elif height is not None:\n            # Resize to specific height, maintain aspect ratio\n            target_height = height\n            target_width = int(orig_width * (height / orig_height))\n            logger.info(f\"Resizing to height {height}, maintaining aspect ratio. Target width: {target_width}\")\n        else:\n            logger.error(\"No resize parameters provided (width, height, or scale_factor)\")\n            raise ValueError(\"Must provide either width, height, or scale_factor\")\n\n        # Resize the image\n        logger.info(f\"Performing resize to {target_width}x{target_height}\")\n        resized_img = cv2.resize(\n            img, (target_width, target_height), interpolation=interp\n        )\n        logger.info(f\"Image resized successfully. New shape: {resized_img.shape}\")\n\n        # Create directory for output if it doesn't exist\n        output_dir = os.path.dirname(output_path)\n        if output_dir and not os.path.exists(output_dir):\n            logger.info(f\"Output directory does not exist, creating: {output_dir}\")\n            os.makedirs(output_dir)\n            logger.info(f\"Output directory created: {output_dir}\")\n\n        # Save the resized image\n        logger.info(f\"Saving resized image to: {output_path}\")\n        cv2.imwrite(output_path, resized_img)\n        logger.info(f\"Resized image saved successfully to: {output_path}\")\n\n        return output_path\n"
  },
  {
    "path": "src/imagesorcery_mcp/tools/rotate.py",
    "content": "import os\nfrom typing import Annotated\n\nimport cv2\nimport imutils\nfrom fastmcp import FastMCP\nfrom pydantic import Field\n\n# Import the central logger\nfrom imagesorcery_mcp.logging_config import logger\n\n\ndef register_tool(mcp: FastMCP):\n    @mcp.tool()\n    def rotate(\n        input_path: Annotated[str, Field(description=\"Full path to the input image (must be a full path)\")],\n        angle: Annotated[\n            float,\n            Field(\n                description=(\n                    \"Angle of rotation in degrees (positive for counterclockwise)\"\n                )\n            ),\n        ],\n        output_path: Annotated[\n            str,\n            Field(\n                description=(\n                    \"Full path to save the output image (must be a full path). \"\n                    \"If not provided, will use input filename \"\n                    \"with '_rotated' suffix.\"\n                )\n            ),\n        ] = None,\n    ) -> str:\n        \"\"\"\n        Rotate an image using imutils.rotate_bound function.\n\n        The function rotates the image by the specified angle in degrees.\n        Positive angles represent counterclockwise rotation.\n        The rotate_bound function ensures the entire rotated image is visible\n        by automatically adjusting the output image size.\n\n        Returns:\n            Path to the rotated image\n        \"\"\"\n        logger.info(f\"Rotate tool requested for image: {input_path} with angle: {angle} degrees\")\n\n        # Check if input file exists\n        if not os.path.exists(input_path):\n            logger.error(f\"Input file not found: {input_path}\")\n            raise FileNotFoundError(f\"Input file not found: {input_path}. Please provide a full path to the file.\")\n        logger.info(f\"Input file found: {input_path}\")\n\n        # Generate output path if not provided\n        if not output_path:\n            file_name, file_ext = os.path.splitext(input_path)\n            output_path = f\"{file_name}_rotated{file_ext}\"\n            logger.info(f\"Output path not provided, generated: {output_path}\")\n\n        # Read the image using OpenCV\n        logger.info(f\"Reading image: {input_path}\")\n        img = cv2.imread(input_path)\n        if img is None:\n            logger.error(f\"Failed to read image: {input_path}\")\n            raise ValueError(f\"Failed to read image: {input_path}\")\n        logger.info(f\"Image read successfully. Shape: {img.shape}\")\n\n        # Rotate the image using imutils.rotate_bound\n        logger.info(f\"Rotating image by {angle} degrees\")\n        rotated_img = imutils.rotate_bound(img, angle)\n        logger.info(f\"Image rotated successfully. New shape: {rotated_img.shape}\")\n\n        # Create directory for output if it doesn't exist\n        output_dir = os.path.dirname(output_path)\n        if output_dir and not os.path.exists(output_dir):\n            logger.info(f\"Output directory does not exist, creating: {output_dir}\")\n            os.makedirs(output_dir)\n            logger.info(f\"Output directory created: {output_dir}\")\n\n        # Save the rotated image\n        logger.info(f\"Saving rotated image to: {output_path}\")\n        cv2.imwrite(output_path, rotated_img)\n        logger.info(f\"Rotated image saved successfully to: {output_path}\")\n\n        return output_path\n"
  },
  {
    "path": "tests/conftest.py",
    "content": "\"\"\"\nPytest configuration file for setting up test environment.\n\"\"\"\n\nimport os\n\n\ndef pytest_configure(config):\n    \"\"\"Configure pytest to set DISABLE_TELEMETRY environment variable for all tests.\"\"\"\n    # Set DISABLE_TELEMETRY environment variable for all tests\n    os.environ['DISABLE_TELEMETRY'] = 'true'\n\n\ndef pytest_unconfigure(config):\n    \"\"\"Clean up after tests if needed.\"\"\"\n    # Optionally remove the environment variable after tests\n    if 'DISABLE_TELEMETRY' in os.environ:\n        del os.environ['DISABLE_TELEMETRY']\n"
  },
  {
    "path": "tests/prompts/test_remove_background.py",
    "content": "import pytest\nfrom fastmcp import Client, FastMCP\n\nfrom imagesorcery_mcp.server import mcp as image_sorcery_mcp_server\n\n\n@pytest.fixture\ndef mcp_server():\n    # Use the existing server instance\n    return image_sorcery_mcp_server\n\n\nclass TestRemoveBackgroundPromptDefinition:\n    \"\"\"Tests for the remove-background prompt definition and metadata.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_remove_background_in_prompts_list(self, mcp_server: FastMCP):\n        \"\"\"Tests that remove-background prompt is in the list of available prompts.\"\"\"\n        async with Client(mcp_server) as client:\n            prompts = await client.list_prompts()\n            # Verify that prompts list is not empty\n            assert prompts, \"Prompts list should not be empty\"\n\n            # Check if remove-background is in the list of prompts\n            prompt_names = [prompt.name for prompt in prompts]\n            assert \"remove-background\" in prompt_names, (\n                \"remove-background prompt should be in the list of available prompts\"\n            )\n\n    @pytest.mark.asyncio\n    async def test_remove_background_description(self, mcp_server: FastMCP):\n        \"\"\"Tests that remove-background prompt has the correct description.\"\"\"\n        async with Client(mcp_server) as client:\n            prompts = await client.list_prompts()\n            remove_bg_prompt = next(\n                (prompt for prompt in prompts if prompt.name == \"remove-background\"), None\n            )\n\n            # Check description\n            assert remove_bg_prompt.description, (\n                \"remove-background prompt should have a description\"\n            )\n            assert \"background removal\" in remove_bg_prompt.description.lower(), (\n                \"Description should mention background removal\"\n            )\n\n    @pytest.mark.asyncio\n    async def test_remove_background_parameters(self, mcp_server: FastMCP):\n        \"\"\"Tests that remove-background prompt has the correct parameter structure.\"\"\"\n        async with Client(mcp_server) as client:\n            prompts = await client.list_prompts()\n            remove_bg_prompt = next(\n                (prompt for prompt in prompts if prompt.name == \"remove-background\"), None\n            )\n\n            # Check arguments schema\n            assert hasattr(remove_bg_prompt, \"arguments\"), (\n                \"remove-background prompt should have an arguments field\"\n            )\n            assert isinstance(remove_bg_prompt.arguments, list), (\n                \"arguments should be a list of PromptArgument objects\"\n            )\n\n            # Get argument names for easier checking\n            arg_names = [arg.name for arg in remove_bg_prompt.arguments]\n\n            # Check required parameters\n            required_params = [\"image_path\"]\n            for param in required_params:\n                assert param in arg_names, (\n                    f\"remove-background prompt should have a '{param}' argument\"\n                )\n\n            # Check optional parameters\n            assert \"target_objects\" in arg_names, (\n                \"remove-background prompt should have a 'target_objects' argument\"\n            )\n            assert \"output_path\" in arg_names, (\n                \"remove-background prompt should have an 'output_path' argument\"\n            )\n\n            # Check parameter requirements\n            image_path_arg = next(arg for arg in remove_bg_prompt.arguments if arg.name == \"image_path\")\n            target_objects_arg = next(arg for arg in remove_bg_prompt.arguments if arg.name == \"target_objects\")\n            output_path_arg = next(arg for arg in remove_bg_prompt.arguments if arg.name == \"output_path\")\n\n            assert image_path_arg.required, \"image_path should be required\"\n            assert not target_objects_arg.required, \"target_objects should be optional\"\n            assert not output_path_arg.required, \"output_path should be optional\"\n\n\nclass TestRemoveBackgroundPromptExecution:\n    \"\"\"Tests for the remove-background prompt execution and results.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_remove_background_prompt_execution(self, mcp_server: FastMCP):\n        \"\"\"Tests the remove-background prompt execution and return value.\"\"\"\n        async with Client(mcp_server) as client:\n            result = await client.get_prompt(\n                \"remove-background\",\n                {\n                    \"image_path\": \"/test/path/image.jpg\",\n                    \"target_objects\": \"person\",\n                    \"output_path\": \"/test/path/output.png\",\n                },\n            )\n\n            # Check that the prompt returned a result\n            assert result.messages, \"Prompt should return messages\"\n            assert len(result.messages) > 0, \"Prompt should return at least one message\"\n\n            # Check the content of the returned prompt\n            prompt_content = result.messages[0].content.text\n            assert \"Step 1:\" in prompt_content, \"Prompt should contain step-by-step instructions\"\n            assert \"detect\" not in prompt_content, \"Prompt should not mention detect tool when target_objects specified\"\n            assert \"fill\" in prompt_content, \"Prompt should mention fill tool\"\n            assert \"find\" in prompt_content, \"Prompt should mention find tool when target_objects specified\"\n            assert \"person\" in prompt_content, \"Prompt should include the target objects\"\n            assert \"/test/path/image.jpg\" in prompt_content, \"Prompt should include the input path\"\n            assert \"/test/path/output.png\" in prompt_content, \"Prompt should include the output path\"\n\n    @pytest.mark.asyncio\n    async def test_remove_background_default_parameters(self, mcp_server: FastMCP):\n        \"\"\"Tests the remove-background prompt with default parameters.\"\"\"\n        async with Client(mcp_server) as client:\n            result = await client.get_prompt(\n                \"remove-background\",\n                {\n                    \"image_path\": \"/test/path/photo.jpg\",\n                },\n            )\n\n            # Check that the prompt returned a result\n            assert result.messages, \"Prompt should return messages\"\n            prompt_content = result.messages[0].content.text\n\n            # Check default behavior (no target_objects specified)\n            assert \"find\" not in prompt_content, (\n                \"Prompt should not use find tool when no target_objects specified\"\n            )\n            assert \"detect\" in prompt_content, (\n                \"Prompt should use detect tool when no target_objects specified\"\n            )\n            assert \"/test/path/photo_no_background.png\" in prompt_content, (\n                \"Prompt should auto-generate output path\"\n            )\n\n    @pytest.mark.asyncio\n    async def test_remove_background_custom_target(self, mcp_server: FastMCP):\n        \"\"\"Tests the remove-background prompt with custom target objects.\"\"\"\n        async with Client(mcp_server) as client:\n            result = await client.get_prompt(\n                \"remove-background\",\n                {\n                    \"image_path\": \"/test/path/car.jpg\",\n                    \"target_objects\": \"red car\",\n                },\n            )\n\n            # Check that the prompt returned a result\n            assert result.messages, \"Prompt should return messages\"\n            prompt_content = result.messages[0].content.text\n\n            # Check custom target objects is used\n            assert \"red car\" in prompt_content, (\n                \"Prompt should use custom target_objects 'red car'\"\n            )\n            assert \"preserving the red car\" in prompt_content, (\n                \"Prompt should mention preserving the custom target\"\n            )\n            assert \"find\" in prompt_content, (\n                \"Prompt should include find tool when target_objects specified\"\n            )\n            assert \"detect\" not in prompt_content, (\n                \"Prompt should not include detect tool when target_objects specified\"\n            )\n"
  },
  {
    "path": "tests/resources/test_models.py",
    "content": "import json\nimport os\nfrom pathlib import Path\n\nimport pytest\nfrom fastmcp import Client, FastMCP\n\nfrom imagesorcery_mcp.server import mcp as image_sorcery_mcp_server\n\n\n@pytest.fixture\ndef mcp_server():\n    # Use the existing server instance\n    return image_sorcery_mcp_server\n\n\n@pytest.fixture\ndef test_models_dir(tmp_path):\n    \"\"\"Create a temporary models directory with test model files.\"\"\"\n    # Save the original models directory path\n    original_models_dir = Path(\"models\")\n    original_exists = original_models_dir.exists()\n    \n    # Create a temporary models directory\n    models_dir = tmp_path / \"models\"\n    models_dir.mkdir(exist_ok=True)\n    \n    # Create some test model files\n    test_models = [\"yolov8n.pt\", \"yolov8m.pt\", \"custom_model.pt\"]\n    for model_name in test_models:\n        model_path = models_dir / model_name\n        # Create an empty file\n        model_path.touch()\n    \n    # Create a test model descriptions file\n    descriptions = {\n        \"yolov8n.pt\": \"YOLOv8 Nano - Smallest and fastest model, suitable for edge devices with limited resources.\",\n        \"yolov8m.pt\": \"YOLOv8 Medium - Default model with good balance between accuracy and speed.\"\n    }\n    with open(models_dir / \"model_descriptions.json\", \"w\") as f:\n        json.dump(descriptions, f)\n    \n    # Temporarily replace the models directory\n    if original_exists:\n        # Rename the original directory\n        temp_original = original_models_dir.with_name(\"models_original_backup\")\n        original_models_dir.rename(temp_original)\n    \n    # Create a symlink to our temporary directory\n    os.symlink(models_dir, original_models_dir)\n    \n    yield models_dir\n    \n    # Clean up: remove the symlink\n    if os.path.islink(original_models_dir):\n        os.unlink(original_models_dir)\n    \n    # Restore the original directory if it existed\n    if original_exists:\n        temp_original.rename(original_models_dir)\n\n\nclass TestModelsResourceDefinition:\n    \"\"\"Tests for the models resource definition and metadata.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_models_in_resources_list(self, mcp_server: FastMCP):\n        \"\"\"Tests that models resource is in the list of available resources.\"\"\"\n        async with Client(mcp_server) as client:\n            resources = await client.list_resources()\n            \n            \n            # Verify that resources list is not empty\n            assert resources, \"Resources list should not be empty\"\n\n            # Check if models://list is in the list of resources\n            # Convert AnyUrl objects to strings\n            resource_uris = [str(resource.uri) for resource in resources]\n            \n            assert \"models://list\" in resource_uris, (\n                f\"models://list resource should be in the list of available resources. \"\n                f\"Found: {resource_uris}\"\n            )\n\n    @pytest.mark.asyncio\n    async def test_models_resource_metadata(self, mcp_server: FastMCP):\n        \"\"\"Tests that models resource has the correct metadata.\"\"\"\n        async with Client(mcp_server) as client:\n            resources = await client.list_resources()\n            \n            \n            models_resource = next(\n                (resource for resource in resources if str(resource.uri) == \"models://list\"), None\n            )\n\n            # Check that the resource exists\n            assert models_resource is not None, f\"models://list resource should exist. Found resources: {[str(r.uri) for r in resources]}\"\n            \n            # Check name - it appears FastMCP uses the full URI as the name\n            assert models_resource.name == \"list_models\", f\"Resource name should be 'list_models' but got '{models_resource.name}'\"\n            \n            # Since description is None, let's skip this check for now or check for None\n            # The actual resource implementation might not set a description at the transport level\n\nclass TestModelsResourceExecution:\n    \"\"\"Tests for the models resource execution and results.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_models_resource_execution(self, mcp_server: FastMCP, test_models_dir):\n        \"\"\"Tests the models resource execution and return value.\"\"\"\n        async with Client(mcp_server) as client:\n            result = await client.read_resource(\"models://list\")\n            \n            # Check that the resource returned a result\n            assert len(result) == 1\n            \n            # Parse the result\n            result_dict = json.loads(result[0].text)\n            \n            # Check that the result has the expected structure\n            assert \"models\" in result_dict\n            assert isinstance(result_dict[\"models\"], list)\n            \n            # Check that we have the expected number of models\n            assert len(result_dict[\"models\"]) == 3\n            \n            # Check that each model has the expected fields\n            for model in result_dict[\"models\"]:\n                assert \"name\" in model\n                assert \"description\" in model\n                assert \"path\" in model\n                \n                # Check specific models\n                if model[\"name\"] == \"yolov8n.pt\":\n                    assert \"Smallest and fastest\" in model[\"description\"]\n                elif model[\"name\"] == \"yolov8m.pt\":\n                    assert \"Default model\" in model[\"description\"]\n                elif model[\"name\"] == \"custom_model.pt\":\n                    assert model[\"description\"] == \"Model 'custom_model.pt' not found in model_descriptions.json (total descriptions: 2)\"\n\n    @pytest.mark.asyncio\n    async def test_models_empty_directory(self, mcp_server: FastMCP, tmp_path):\n        \"\"\"Tests the models resource with an empty models directory.\"\"\"\n        # Save the original models directory path\n        original_models_dir = Path(\"models\")\n        original_exists = original_models_dir.exists()\n        \n        if original_exists:\n            # Rename the original directory\n            temp_original = original_models_dir.with_name(\"models_original_backup\")\n            original_models_dir.rename(temp_original)\n        \n        # Create an empty models directory\n        empty_models_dir = tmp_path / \"empty_models\"\n        empty_models_dir.mkdir(exist_ok=True)\n        os.symlink(empty_models_dir, original_models_dir)\n        \n        try:\n            async with Client(mcp_server) as client:\n                result = await client.read_resource(\"models://list\")\n                \n                # Check that the resource returned a result\n                assert len(result) == 1\n                \n                # Parse the result\n                result_dict = json.loads(result[0].text)\n                \n                # Check that the result has the expected structure\n                assert \"models\" in result_dict\n                assert isinstance(result_dict[\"models\"], list)\n                \n                # Check that the list is empty\n                assert len(result_dict[\"models\"]) == 0\n        finally:\n            # Clean up: remove the symlink\n            if os.path.islink(original_models_dir):\n                os.unlink(original_models_dir)\n            \n            # Restore the original directory if it existed\n            if original_exists:\n                temp_original.rename(original_models_dir)\n\n    @pytest.mark.asyncio\n    async def test_models_no_directory(self, mcp_server: FastMCP, tmp_path):\n        \"\"\"Tests the models resource when the models directory doesn't exist.\"\"\"\n        # Save the original models directory path\n        original_models_dir = Path(\"models\")\n        original_exists = original_models_dir.exists()\n        \n        # Remove the original directory if it exists\n        if original_exists:\n            # Rename the original directory\n            temp_original = original_models_dir.with_name(\"models_original_backup\")\n            original_models_dir.rename(temp_original)\n        \n        try:\n            async with Client(mcp_server) as client:\n                result = await client.read_resource(\"models://list\")\n                \n                # Check that the resource returned a result\n                assert len(result) == 1\n                \n                # Parse the result\n                result_dict = json.loads(result[0].text)\n                \n                # Check that the result has the expected structure\n                assert \"models\" in result_dict\n                assert isinstance(result_dict[\"models\"], list)\n                \n                # Check that the list is empty when directory doesn't exist\n                assert len(result_dict[\"models\"]) == 0\n        finally:\n            # Restore the original directory if it existed\n            if original_exists:\n                temp_original.rename(original_models_dir)\n\n    @pytest.mark.asyncio\n    async def test_models_with_subdirectories(self, mcp_server: FastMCP, tmp_path):\n        \"\"\"Tests the models resource with models in subdirectories.\"\"\"\n        # Save the original models directory path\n        original_models_dir = Path(\"models\")\n        original_exists = original_models_dir.exists()\n        \n        if original_exists:\n            # Rename the original directory\n            temp_original = original_models_dir.with_name(\"models_original_backup\")\n            original_models_dir.rename(temp_original)\n        \n        # Create a models directory with subdirectories\n        models_dir = tmp_path / \"models\"\n        models_dir.mkdir(exist_ok=True)\n        \n        # Create subdirectories and model files\n        (models_dir / \"detection\").mkdir()\n        (models_dir / \"detection\" / \"yolov8n.pt\").touch()\n        (models_dir / \"segmentation\").mkdir()\n        (models_dir / \"segmentation\" / \"sam.onnx\").touch()\n        (models_dir / \"root_model.pt\").touch()\n        \n        # Create descriptions file\n        descriptions = {\n            \"detection/yolov8n.pt\": \"YOLOv8 Nano for object detection\",\n            \"segmentation/sam.onnx\": \"Segment Anything Model\",\n            \"root_model.pt\": \"Model in root directory\"\n        }\n        with open(models_dir / \"model_descriptions.json\", \"w\") as f:\n            json.dump(descriptions, f)\n        \n        # Create a symlink to our temporary directory\n        os.symlink(models_dir, original_models_dir)\n        \n        try:\n            async with Client(mcp_server) as client:\n                result = await client.read_resource(\"models://list\")\n                \n                # Check that the resource returned a result\n                assert len(result) == 1\n                \n                # Parse the result\n                result_dict = json.loads(result[0].text)\n                \n                # Check that the result has the expected structure\n                assert \"models\" in result_dict\n                assert isinstance(result_dict[\"models\"], list)\n                \n                # Check that we have all the models\n                assert len(result_dict[\"models\"]) == 3\n                \n                # Check model names\n                model_names = [model[\"name\"] for model in result_dict[\"models\"]]\n                assert \"detection/yolov8n.pt\" in model_names\n                assert \"segmentation/sam.onnx\" in model_names\n                assert \"root_model.pt\" in model_names\n                \n                # Check descriptions\n                for model in result_dict[\"models\"]:\n                    if model[\"name\"] == \"detection/yolov8n.pt\":\n                        assert model[\"description\"] == \"YOLOv8 Nano for object detection\"\n                    elif model[\"name\"] == \"segmentation/sam.onnx\":\n                        assert model[\"description\"] == \"Segment Anything Model\"\n                    elif model[\"name\"] == \"root_model.pt\":\n                        assert model[\"description\"] == \"Model in root directory\"\n        finally:\n            # Clean up: remove the symlink\n            if os.path.islink(original_models_dir):\n                os.unlink(original_models_dir)\n            \n            # Restore the original directory if it existed\n            if original_exists:\n                temp_original.rename(original_models_dir)\n\n    @pytest.mark.asyncio\n    async def test_models_ignores_non_model_files(self, mcp_server: FastMCP, tmp_path):\n        \"\"\"Tests that the models resource ignores non-model files.\"\"\"\n        # Save the original models directory path\n        original_models_dir = Path(\"models\")\n        original_exists = original_models_dir.exists()\n        \n        if original_exists:\n            # Rename the original directory\n            temp_original = original_models_dir.with_name(\"models_original_backup\")\n            original_models_dir.rename(temp_original)\n        \n        # Create a models directory with various files\n        models_dir = tmp_path / \"models\"\n        models_dir.mkdir(exist_ok=True)\n        \n        # Create model and non-model files\n        (models_dir / \"model1.pt\").touch()\n        (models_dir / \"model2.onnx\").touch()\n        (models_dir / \"readme.txt\").touch()\n        (models_dir / \"config.json\").touch()\n        (models_dir / \"image.jpg\").touch()\n        \n        # Create descriptions file\n        descriptions = {\n            \"model1.pt\": \"PyTorch model\",\n            \"model2.onnx\": \"ONNX model\"\n        }\n        with open(models_dir / \"model_descriptions.json\", \"w\") as f:\n            json.dump(descriptions, f)\n        \n        # Create a symlink to our temporary directory\n        os.symlink(models_dir, original_models_dir)\n        \n        try:\n            async with Client(mcp_server) as client:\n                result = await client.read_resource(\"models://list\")\n                \n                # Check that the resource returned a result\n                assert len(result) == 1\n                \n                # Parse the result\n                result_dict = json.loads(result[0].text)\n                \n                # Check that the result has the expected structure\n                assert \"models\" in result_dict\n                assert isinstance(result_dict[\"models\"], list)\n                \n                # Check that we have only the model files\n                assert len(result_dict[\"models\"]) == 2\n                \n                # Check model names\n                model_names = [model[\"name\"] for model in result_dict[\"models\"]]\n                assert \"model1.pt\" in model_names\n                assert \"model2.onnx\" in model_names\n                \n                # Non-model files should not be included\n                assert \"readme.txt\" not in model_names\n                assert \"config.json\" not in model_names\n                assert \"image.jpg\" not in model_names\n                assert \"model_descriptions.json\" not in model_names\n        finally:\n            # Clean up: remove the symlink\n            if os.path.islink(original_models_dir):\n                os.unlink(original_models_dir)\n            \n            # Restore the original directory if it existed\n            if original_exists:\n                temp_original.rename(original_models_dir)\n"
  },
  {
    "path": "tests/test_config.py",
    "content": "\"\"\"\nTests for the configuration management system.\n\"\"\"\n\nimport os\nimport tempfile\nfrom pathlib import Path\n\nimport pytest\nimport toml\n\nfrom imagesorcery_mcp.config import (\n    ConfigManager,\n    ImageSorceryConfig,\n    get_config,\n    get_config_manager,\n)\n\n\nclass TestImageSorceryConfig:\n    \"\"\"Tests for the ImageSorceryConfig model.\"\"\"\n\n    def test_default_values(self):\n        \"\"\"Test that default configuration values are correct.\"\"\"\n        config = ImageSorceryConfig()\n        \n        # Detection defaults\n        assert config.detection.confidence_threshold == 0.75\n        assert config.detection.default_model == \"yoloe-11l-seg-pf.pt\"\n        \n        # Find defaults\n        assert config.find.confidence_threshold == 0.75\n        assert config.find.default_model == \"yoloe-11l-seg.pt\"\n        \n        # Blur defaults\n        assert config.blur.strength == 15\n        \n        # Text defaults\n        assert config.text.font_scale == 1.0\n        \n        # Drawing defaults\n        assert config.drawing.color == [0, 0, 0]\n        assert config.drawing.thickness == 1\n        \n        # OCR defaults\n        assert config.ocr.language == \"en\"\n        \n        # Resize defaults\n        assert config.resize.interpolation == \"linear\"\n\n        # Telemetry defaults\n        assert config.telemetry.enabled is False\n\n    def test_validation_confidence_threshold(self):\n        \"\"\"Test validation of confidence thresholds.\"\"\"\n        # Valid values\n        config = ImageSorceryConfig(detection={\"confidence_threshold\": 0.5})\n        assert config.detection.confidence_threshold == 0.5\n        \n        # Invalid values\n        with pytest.raises(ValueError):\n            ImageSorceryConfig(detection={\"confidence_threshold\": 1.5})\n        \n        with pytest.raises(ValueError):\n            ImageSorceryConfig(detection={\"confidence_threshold\": -0.1})\n\n    def test_validation_blur_strength(self):\n        \"\"\"Test validation of blur strength.\"\"\"\n        # Valid odd values\n        config = ImageSorceryConfig(blur={\"strength\": 21})\n        assert config.blur.strength == 21\n        \n        # Invalid even values\n        with pytest.raises(ValueError):\n            ImageSorceryConfig(blur={\"strength\": 20})\n\n    def test_validation_drawing_color(self):\n        \"\"\"Test validation of drawing color.\"\"\"\n        # Valid color\n        config = ImageSorceryConfig(drawing={\"color\": [255, 128, 0]})\n        assert config.drawing.color == [255, 128, 0]\n        \n        # Invalid color values\n        with pytest.raises(ValueError):\n            ImageSorceryConfig(drawing={\"color\": [256, 0, 0]})\n        \n        with pytest.raises(ValueError):\n            ImageSorceryConfig(drawing={\"color\": [-1, 0, 0]})\n        \n        # Invalid color length\n        with pytest.raises(ValueError):\n            ImageSorceryConfig(drawing={\"color\": [255, 0]})\n\n    def test_validation_interpolation(self):\n        \"\"\"Test validation of resize interpolation.\"\"\"\n        # Valid interpolation methods\n        for method in [\"nearest\", \"linear\", \"area\", \"cubic\", \"lanczos\"]:\n            config = ImageSorceryConfig(resize={\"interpolation\": method})\n            assert config.resize.interpolation == method\n        \n        # Invalid interpolation method\n        with pytest.raises(ValueError):\n            ImageSorceryConfig(resize={\"interpolation\": \"invalid\"})\n\n    def test_validation_telemetry_enabled(self):\n        \"\"\"Test validation of telemetry enabled flag.\"\"\"\n        # Valid values\n        config = ImageSorceryConfig(telemetry={\"enabled\": True})\n        assert config.telemetry.enabled is True\n\n        config = ImageSorceryConfig(telemetry={\"enabled\": False})\n        assert config.telemetry.enabled is False\n\n        # Invalid values\n        with pytest.raises(ValueError):\n            ImageSorceryConfig(telemetry={\"enabled\": \"not_a_bool\"})\n\n\nclass TestConfigManager:\n    \"\"\"Tests for the ConfigManager class.\"\"\"\n\n    def setup_method(self):\n        \"\"\"Set up test environment.\"\"\"\n        self.temp_dir = tempfile.mkdtemp()\n        self.original_cwd = os.getcwd()\n        os.chdir(self.temp_dir)\n\n    def teardown_method(self):\n        \"\"\"Clean up test environment.\"\"\"\n        os.chdir(self.original_cwd)\n        import shutil\n        shutil.rmtree(self.temp_dir)\n\n    def test_config_file_creation(self):\n        \"\"\"Test that config file is created if it doesn't exist.\"\"\"\n        ConfigManager()\n\n        # Check that config.toml was created\n        assert Path(\"config.toml\").exists()\n        \n        # Check that it contains valid TOML\n        with open(\"config.toml\", \"r\") as f:\n            config_data = toml.load(f)\n        \n        assert \"detection\" in config_data\n        assert \"blur\" in config_data\n\n    def test_config_loading_from_file(self):\n        \"\"\"Test loading configuration from existing file.\"\"\"\n        # Create a config file with custom values\n        config_data = {\n            \"detection\": {\"confidence_threshold\": 0.8},\n            \"blur\": {\"strength\": 21}\n        }\n        \n        with open(\"config.toml\", \"w\") as f:\n            toml.dump(config_data, f)\n        \n        config_manager = ConfigManager()\n        config = config_manager.config\n        \n        assert config.detection.confidence_threshold == 0.8\n        assert config.blur.strength == 21\n\n    def test_runtime_updates(self):\n        \"\"\"Test runtime configuration updates.\"\"\"\n        config_manager = ConfigManager()\n        \n        # Update configuration\n        updates = {\n            \"detection.confidence_threshold\": 0.9,\n            \"text.font_scale\": 2.0\n        }\n        \n        updated_config = config_manager.update_config(updates, persist=False)\n        \n        assert updated_config[\"detection\"][\"confidence_threshold\"] == 0.9\n        assert updated_config[\"text\"][\"font_scale\"] == 2.0\n        \n        # Check that file wasn't modified\n        with open(\"config.toml\", \"r\") as f:\n            file_config = toml.load(f)\n        \n        # Should still have defaults since we didn't persist\n        assert file_config.get(\"detection\", {}).get(\"confidence_threshold\", 0.75) == 0.75\n\n    def test_persistent_updates(self):\n        \"\"\"Test persistent configuration updates.\"\"\"\n        config_manager = ConfigManager()\n        \n        # Update configuration with persistence\n        updates = {\n            \"detection.confidence_threshold\": 0.85,\n            \"ocr.language\": \"fr\"\n        }\n        \n        config_manager.update_config(updates, persist=True)\n        \n        # Check that file was modified\n        with open(\"config.toml\", \"r\") as f:\n            file_config = toml.load(f)\n        \n        assert file_config[\"detection\"][\"confidence_threshold\"] == 0.85\n        assert file_config[\"ocr\"][\"language\"] == \"fr\"\n\n    def test_persistent_telemetry_update(self):\n        \"\"\"Test persistent telemetry configuration update.\"\"\"\n        config_manager = ConfigManager()\n\n        # Update telemetry with persistence\n        updates = {\n            \"telemetry.enabled\": True\n        }\n\n        config_manager.update_config(updates, persist=True)\n\n        # Check that file was modified\n        with open(\"config.toml\", \"r\") as f:\n            file_config = toml.load(f)\n\n        assert file_config[\"telemetry\"][\"enabled\"] is True\n\n        # Verify the runtime config also reflects the change\n        config = config_manager.config\n        assert config.telemetry.enabled is True\n\n    def test_validation_in_updates(self):\n        \"\"\"Test that updates are validated.\"\"\"\n        config_manager = ConfigManager()\n        \n        # Invalid confidence threshold\n        with pytest.raises(ValueError):\n            config_manager.update_config({\"detection.confidence_threshold\": 1.5})\n        \n        # Invalid blur strength\n        with pytest.raises(ValueError):\n            config_manager.update_config({\"blur.strength\": 20})\n\n    def test_reset_runtime_overrides(self):\n        \"\"\"Test resetting runtime overrides.\"\"\"\n        config_manager = ConfigManager()\n        \n        # Make runtime changes\n        config_manager.update_config({\n            \"detection.confidence_threshold\": 0.9,\n            \"text.font_scale\": 2.0\n        }, persist=False)\n        \n        # Verify changes\n        config = config_manager.config\n        assert config.detection.confidence_threshold == 0.9\n        assert config.text.font_scale == 2.0\n        \n        # Reset\n        config_manager.reset_runtime_overrides()\n        \n        # Verify reset\n        config = config_manager.config\n        assert config.detection.confidence_threshold == 0.75  # Back to default\n        assert config.text.font_scale == 1.0  # Back to default\n\n    def test_get_runtime_overrides(self):\n        \"\"\"Test getting current runtime overrides.\"\"\"\n        config_manager = ConfigManager()\n        \n        # Initially no overrides\n        assert config_manager.get_runtime_overrides() == {}\n        \n        # Add some overrides\n        config_manager.update_config({\n            \"detection.confidence_threshold\": 0.9\n        }, persist=False)\n        \n        overrides = config_manager.get_runtime_overrides()\n        assert overrides[\"detection.confidence_threshold\"] == 0.9\n\n\nclass TestGlobalConfigFunctions:\n    \"\"\"Tests for global configuration functions.\"\"\"\n\n    def setup_method(self):\n        \"\"\"Set up test environment.\"\"\"\n        self.temp_dir = tempfile.mkdtemp()\n        self.original_cwd = os.getcwd()\n        os.chdir(self.temp_dir)\n        \n        # Reset global config manager\n        import imagesorcery_mcp.config\n        imagesorcery_mcp.config._config_manager = None\n\n    def teardown_method(self):\n        \"\"\"Clean up test environment.\"\"\"\n        os.chdir(self.original_cwd)\n        import shutil\n        shutil.rmtree(self.temp_dir)\n        \n        # Reset global config manager\n        import imagesorcery_mcp.config\n        imagesorcery_mcp.config._config_manager = None\n\n    def test_get_config_manager(self):\n        \"\"\"Test get_config_manager function.\"\"\"\n        manager1 = get_config_manager()\n        manager2 = get_config_manager()\n        \n        # Should return the same instance\n        assert manager1 is manager2\n\n    def test_get_config(self):\n        \"\"\"Test get_config function.\"\"\"\n        config = get_config()\n        \n        assert isinstance(config, ImageSorceryConfig)\n        assert config.detection.confidence_threshold == 0.75\n"
  },
  {
    "path": "tests/test_logging.py",
    "content": "import inspect\nimport logging\nimport os\nimport re\nimport tempfile\nimport time\nfrom datetime import datetime\n\nimport pytest\n\nfrom imagesorcery_mcp.logging_config import logger as imagesorcery_logger\n\n\n@pytest.fixture\ndef temp_log_file():\n    \"\"\"Create a temporary log file for testing.\"\"\"\n    fd, path = tempfile.mkstemp(suffix='.log')\n    yield path\n    os.close(fd)\n    os.unlink(path)\n\ndef test_log_structure_and_components(temp_log_file):\n    \"\"\"\n    Test that logs have the correct structure and all components are properly formatted\n    using the actual logging configuration from the project.\n    \"\"\"\n    # Get the actual imagesorcery logger initialized by logging_config\n    # Create a temporary handler to capture logs\n    handler = logging.FileHandler(temp_log_file)\n    # Use the same formatter as the original logger\n    original_formatter = imagesorcery_logger.handlers[0].formatter\n    handler.setFormatter(original_formatter)\n    imagesorcery_logger.addHandler(handler)\n    \n    # Generate a test log with a unique message\n    test_message = f\"Test message generated at {time.time()}\"\n    line_num = inspect.currentframe().f_lineno + 1\n    imagesorcery_logger.info(test_message)\n    \n    # Remove the temporary handler\n    imagesorcery_logger.removeHandler(handler)\n    \n    # Read the log file\n    with open(temp_log_file, 'r') as f:\n        log_content = f.read().strip()\n    \n    # Log format regex pattern to capture each component\n    # This pattern should match the format defined in logging_config.py\n    log_pattern = r'(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2},\\d{3}) - ([\\w\\.]+)\\.(\\w+):(\\d+) - (\\w+) - (.+)'\n    match = re.match(log_pattern, log_content)\n    \n    # Verify we matched the pattern\n    assert match, f\"Log entry doesn't match expected pattern. Log content: {log_content}\"\n    \n    # Extract components\n    timestamp, logger_name, module_name, log_line_num, level, message = match.groups()\n    \n    # Verify each component\n    \n    # 1. Timestamp should be parseable\n    try:\n        datetime.strptime(timestamp, '%Y-%m-%d %H:%M:%S,%f')\n    except ValueError:\n        pytest.fail(f\"Invalid timestamp format: {timestamp}\")\n    \n    # 2. Logger name should match what we set\n    assert logger_name == \"imagesorcery\", f\"Expected logger name 'imagesorcery', got '{logger_name}'\"\n    \n    # 3. Module name should be this test module\n    assert module_name == \"test_logging\", f\"Expected module name 'test_logging', got '{module_name}'\"\n    \n    # 4. Line number should match our recording\n    assert int(log_line_num) == line_num, f\"Expected line number {line_num}, got {log_line_num}\"\n    \n    # 5. Level should be what we logged\n    assert level == \"INFO\", f\"Expected log level 'INFO', got '{level}'\"\n    \n    # 6. Message should match what we logged\n    assert message == test_message, f\"Expected message '{test_message}', got '{message}'\"\n\ndef test_different_modules_log_correctly(temp_log_file):\n    \"\"\"Test that logs from different modules include correct module names.\"\"\"\n    # Get the actual imagesorcery logger initialized by logging_config\n    # Setup a new handler for our test log file using the same formatter\n    handler = logging.FileHandler(temp_log_file)\n    original_formatter = imagesorcery_logger.handlers[0].formatter\n    handler.setFormatter(original_formatter)\n    \n    # Add our handler to the logger\n    imagesorcery_logger.addHandler(handler)\n    \n    # Log a message and get the current line number\n    line_num = inspect.currentframe().f_lineno + 1\n    imagesorcery_logger.info(\"Test message from test function\")\n    \n    # Remove our handler\n    imagesorcery_logger.removeHandler(handler)\n    \n    # Check the log file content\n    with open(temp_log_file, 'r') as f:\n        log_content = f.read()\n    \n    # Verify the module name and line number are in the log\n    assert f'imagesorcery.test_logging:{line_num}' in log_content, \"Log doesn't contain module info\"\n    assert 'Test message from test function' in log_content, \"Log message not written correctly\"\n\ndef test_different_log_levels(temp_log_file):\n    \"\"\"\n    Test that different log levels are correctly formatted and filtered \n    according to the logger's level setting.\n    \"\"\"\n    # Get the actual imagesorcery logger initialized by logging_config\n    # Store the original level to restore it later\n    original_level = imagesorcery_logger.level\n    \n    # Create a temporary handler to capture logs\n    handler = logging.FileHandler(temp_log_file)\n    original_formatter = imagesorcery_logger.handlers[0].formatter\n    handler.setFormatter(original_formatter)\n    imagesorcery_logger.addHandler(handler)\n    \n    try:\n        # Test with different log levels\n        \n        # 1. First test with DEBUG level (lower than default INFO)\n        imagesorcery_logger.setLevel(logging.DEBUG)\n        \n        # Log messages at different levels\n        debug_msg = \"This is a DEBUG message\"\n        info_msg = \"This is an INFO message\"\n        warning_msg = \"This is a WARNING message\"\n        error_msg = \"This is an ERROR message\"\n        critical_msg = \"This is a CRITICAL message\"\n        \n        imagesorcery_logger.debug(debug_msg)\n        imagesorcery_logger.info(info_msg)\n        imagesorcery_logger.warning(warning_msg)\n        imagesorcery_logger.error(error_msg)\n        imagesorcery_logger.critical(critical_msg)\n        \n        # Read the log file\n        with open(temp_log_file, 'r') as f:\n            debug_level_logs = f.readlines()\n        \n        # There should be 5 log entries (one for each level) when set to DEBUG\n        assert len(debug_level_logs) == 5, f\"Expected 5 log entries at DEBUG level, got {len(debug_level_logs)}\"\n        \n        # Verify each log level appears in the correct entry\n        assert \"DEBUG\" in debug_level_logs[0], f\"First log should be DEBUG: {debug_level_logs[0]}\"\n        assert \"INFO\" in debug_level_logs[1], f\"Second log should be INFO: {debug_level_logs[1]}\"\n        assert \"WARNING\" in debug_level_logs[2], f\"Third log should be WARNING: {debug_level_logs[2]}\"\n        assert \"ERROR\" in debug_level_logs[3], f\"Fourth log should be ERROR: {debug_level_logs[3]}\"\n        assert \"CRITICAL\" in debug_level_logs[4], f\"Fifth log should be CRITICAL: {debug_level_logs[4]}\"\n        \n        # Verify messages are correctly logged\n        assert debug_msg in debug_level_logs[0], f\"DEBUG message not correctly logged: {debug_level_logs[0]}\"\n        assert info_msg in debug_level_logs[1], f\"INFO message not correctly logged: {debug_level_logs[1]}\"\n        assert warning_msg in debug_level_logs[2], f\"WARNING message not correctly logged: {debug_level_logs[2]}\"\n        assert error_msg in debug_level_logs[3], f\"ERROR message not correctly logged: {debug_level_logs[3]}\"\n        assert critical_msg in debug_level_logs[4], f\"CRITICAL message not correctly logged: {debug_level_logs[4]}\"\n        \n        # 2. Now test with INFO level (default level)\n        # Clear the log file first\n        open(temp_log_file, 'w').close()\n        \n        imagesorcery_logger.setLevel(logging.INFO)\n        \n        # Log messages at different levels again\n        imagesorcery_logger.debug(\"This shouldn't appear in the log\")\n        imagesorcery_logger.info(info_msg)\n        imagesorcery_logger.warning(warning_msg)\n        imagesorcery_logger.error(error_msg)\n        imagesorcery_logger.critical(critical_msg)\n        \n        # Read the log file again\n        with open(temp_log_file, 'r') as f:\n            info_level_logs = f.readlines()\n        \n        # There should be 4 log entries (DEBUG should be filtered out)\n        assert len(info_level_logs) == 4, f\"Expected 4 log entries at INFO level, got {len(info_level_logs)}\"\n        \n        # Verify each log level appears in the correct entry\n        assert \"INFO\" in info_level_logs[0], f\"First log should be INFO: {info_level_logs[0]}\"\n        assert \"WARNING\" in info_level_logs[1], f\"Second log should be WARNING: {info_level_logs[1]}\"\n        assert \"ERROR\" in info_level_logs[2], f\"Third log should be ERROR: {info_level_logs[2]}\"\n        assert \"CRITICAL\" in info_level_logs[3], f\"Fourth log should be CRITICAL: {info_level_logs[3]}\"\n        \n        # 3. Test with WARNING level\n        # Clear the log file first\n        open(temp_log_file, 'w').close()\n        \n        imagesorcery_logger.setLevel(logging.WARNING)\n        \n        # Log messages at different levels again\n        imagesorcery_logger.debug(\"This shouldn't appear in the log\")\n        imagesorcery_logger.info(\"This shouldn't appear in the log either\")\n        imagesorcery_logger.warning(warning_msg)\n        imagesorcery_logger.error(error_msg)\n        imagesorcery_logger.critical(critical_msg)\n        \n        # Read the log file again\n        with open(temp_log_file, 'r') as f:\n            warning_level_logs = f.readlines()\n        \n        # There should be 3 log entries (DEBUG and INFO should be filtered out)\n        assert len(warning_level_logs) == 3, f\"Expected 3 log entries at WARNING level, got {len(warning_level_logs)}\"\n        \n        # Verify each log level appears in the correct entry\n        assert \"WARNING\" in warning_level_logs[0], f\"First log should be WARNING: {warning_level_logs[0]}\"\n        assert \"ERROR\" in warning_level_logs[1], f\"Second log should be ERROR: {warning_level_logs[1]}\"\n        assert \"CRITICAL\" in warning_level_logs[2], f\"Third log should be CRITICAL: {warning_level_logs[2]}\"\n        \n        # 4. Test with ERROR level\n        # Clear the log file first\n        open(temp_log_file, 'w').close()\n        \n        imagesorcery_logger.setLevel(logging.ERROR)\n        \n        # Log messages at different levels again\n        imagesorcery_logger.debug(\"This shouldn't appear in the log\")\n        imagesorcery_logger.info(\"This shouldn't appear in the log either\")\n        imagesorcery_logger.warning(\"This shouldn't appear in the log either\")\n        imagesorcery_logger.error(error_msg)\n        imagesorcery_logger.critical(critical_msg)\n        \n        # Read the log file again\n        with open(temp_log_file, 'r') as f:\n            error_level_logs = f.readlines()\n        \n        # There should be 2 log entries (DEBUG, INFO, WARNING should be filtered out)\n        assert len(error_level_logs) == 2, f\"Expected 2 log entries at ERROR level, got {len(error_level_logs)}\"\n        \n        # Verify each log level appears in the correct entry\n        assert \"ERROR\" in error_level_logs[0], f\"First log should be ERROR: {error_level_logs[0]}\"\n        assert \"CRITICAL\" in error_level_logs[1], f\"Second log should be CRITICAL: {error_level_logs[1]}\"\n        \n        # 5. Test with CRITICAL level\n        # Clear the log file first\n        open(temp_log_file, 'w').close()\n        \n        imagesorcery_logger.setLevel(logging.CRITICAL)\n        \n        # Log messages at different levels again\n        imagesorcery_logger.debug(\"This shouldn't appear in the log\")\n        imagesorcery_logger.info(\"This shouldn't appear in the log either\")\n        imagesorcery_logger.warning(\"This shouldn't appear in the log either\")\n        imagesorcery_logger.error(\"This shouldn't appear in the log either\")\n        imagesorcery_logger.critical(critical_msg)\n        \n        # Read the log file again\n        with open(temp_log_file, 'r') as f:\n            critical_level_logs = f.readlines()\n        \n        # There should be 1 log entry (all others should be filtered out)\n        assert len(critical_level_logs) == 1, f\"Expected 1 log entry at CRITICAL level, got {len(critical_level_logs)}\"\n        \n        # Verify the log level and message\n        assert \"CRITICAL\" in critical_level_logs[0], f\"Log should be CRITICAL: {critical_level_logs[0]}\"\n        assert critical_msg in critical_level_logs[0], f\"CRITICAL message not correctly logged: {critical_level_logs[0]}\"\n        \n        # Verify that the log format is still correct for each level\n        for log_line in debug_level_logs:\n            # Check that the log format includes module and line number\n            assert re.match(r'.*imagesorcery\\.test_logging:\\d+ - \\w+ -.*', log_line), f\"Log format incorrect: {log_line}\"\n    \n    finally:\n        # Restore the original logger level and remove our handler\n        imagesorcery_logger.setLevel(original_level)\n        imagesorcery_logger.removeHandler(handler)\n"
  },
  {
    "path": "tests/test_path_access.py",
    "content": "import os\n\nimport pytest\nfrom fastmcp import Client, FastMCP\nfrom PIL import Image\n\nfrom imagesorcery_mcp.middlewares.path_access import (\n    AVAILABLE_PATHS_ENV,\n    get_allowed_directories,\n    split_paths,\n)\nfrom imagesorcery_mcp.server import mcp as image_sorcery_mcp_server\n\n\n@pytest.fixture\ndef mcp_server():\n    return image_sorcery_mcp_server\n\n\n@pytest.fixture\ndef test_image(tmp_path):\n    image_dir = tmp_path / \"allowed\"\n    image_dir.mkdir()\n    image_path = image_dir / \"image.png\"\n    Image.new(\"RGB\", (20, 20), color=\"white\").save(image_path)\n    return image_path\n\n\ndef test_available_paths_empty_disables_restrictions(monkeypatch):\n    monkeypatch.delenv(AVAILABLE_PATHS_ENV, raising=False)\n\n    assert get_allowed_directories() == []\n\n    monkeypatch.setenv(AVAILABLE_PATHS_ENV, \"  \")\n\n    assert get_allowed_directories() == []\n\n\ndef test_available_paths_supports_pathsep_and_comma():\n    raw_paths = os.pathsep.join([\"/tmp/images\", \"/tmp/output\"]) + \",/tmp/masks\"\n\n    assert split_paths(raw_paths) == [\"/tmp/images\", \"/tmp/output\", \"/tmp/masks\"]\n\n\n@pytest.mark.asyncio\nasync def test_path_inside_allowed_directory_is_accepted(\n    mcp_server: FastMCP, test_image, monkeypatch\n):\n    monkeypatch.setenv(AVAILABLE_PATHS_ENV, str(test_image.parent))\n\n    async with Client(mcp_server) as client:\n        result = await client.call_tool(\"get_metainfo\", {\"input_path\": str(test_image)})\n\n    assert result.data[\"path\"] == str(test_image)\n\n\n@pytest.mark.asyncio\nasync def test_relative_traversal_outside_allowed_directory_is_rejected(\n    mcp_server: FastMCP, tmp_path, monkeypatch\n):\n    allowed_dir = tmp_path / \"allowed\"\n    outside_dir = tmp_path / \"outside\"\n    allowed_dir.mkdir()\n    outside_dir.mkdir()\n    outside_image = outside_dir / \"image.png\"\n    Image.new(\"RGB\", (20, 20), color=\"white\").save(outside_image)\n\n    monkeypatch.chdir(tmp_path)\n    monkeypatch.setenv(AVAILABLE_PATHS_ENV, str(allowed_dir))\n\n    async with Client(mcp_server) as client:\n        with pytest.raises(Exception) as excinfo:\n            await client.call_tool(\n                \"get_metainfo\",\n                {\"input_path\": \"allowed/../outside/image.png\"},\n            )\n\n    assert \"outside allowed directories\" in str(excinfo.value)\n\n\n@pytest.mark.asyncio\nasync def test_symlink_inside_allowed_directory_is_accepted_without_resolving_target(\n    mcp_server: FastMCP, tmp_path, monkeypatch\n):\n    allowed_dir = tmp_path / \"allowed\"\n    outside_dir = tmp_path / \"outside\"\n    allowed_dir.mkdir()\n    outside_dir.mkdir()\n    outside_image = outside_dir / \"image.png\"\n    Image.new(\"RGB\", (20, 20), color=\"white\").save(outside_image)\n\n    try:\n        (allowed_dir / \"link\").symlink_to(outside_dir, target_is_directory=True)\n    except OSError as exc:\n        pytest.skip(f\"Symlink creation is not available: {exc}\")\n\n    monkeypatch.setenv(AVAILABLE_PATHS_ENV, str(allowed_dir))\n\n    async with Client(mcp_server) as client:\n        result = await client.call_tool(\n            \"get_metainfo\",\n            {\"input_path\": str(allowed_dir / \"link\" / \"image.png\")},\n        )\n\n    assert result.data[\"path\"] == str(allowed_dir / \"link\" / \"image.png\")\n\n\n@pytest.mark.asyncio\nasync def test_output_path_outside_allowed_directory_is_rejected(\n    mcp_server: FastMCP, test_image, tmp_path, monkeypatch\n):\n    outside_output = tmp_path / \"outside\" / \"output.png\"\n    monkeypatch.setenv(AVAILABLE_PATHS_ENV, str(test_image.parent))\n\n    async with Client(mcp_server) as client:\n        with pytest.raises(Exception) as excinfo:\n            await client.call_tool(\n                \"resize\",\n                {\n                    \"input_path\": str(test_image),\n                    \"width\": 10,\n                    \"output_path\": str(outside_output),\n                },\n            )\n\n    assert \"outside allowed directories\" in str(excinfo.value)\n\n\n@pytest.mark.asyncio\nasync def test_nested_mask_path_outside_allowed_directory_is_rejected(\n    mcp_server: FastMCP, test_image, tmp_path, monkeypatch\n):\n    output_path = test_image.parent / \"output.png\"\n    outside_mask_path = tmp_path / \"outside\" / \"mask.png\"\n    monkeypatch.setenv(AVAILABLE_PATHS_ENV, str(test_image.parent))\n\n    async with Client(mcp_server) as client:\n        with pytest.raises(Exception) as excinfo:\n            await client.call_tool(\n                \"fill\",\n                {\n                    \"input_path\": str(test_image),\n                    \"areas\": [{\"mask_path\": str(outside_mask_path), \"color\": [0, 0, 0]}],\n                    \"output_path\": str(output_path),\n                },\n            )\n\n    assert \"areas[0].mask_path\" in str(excinfo.value)\n    assert \"outside allowed directories\" in str(excinfo.value)\n"
  },
  {
    "path": "tests/test_server.py",
    "content": "import pytest\nfrom fastmcp import Client, FastMCP\n\nfrom imagesorcery_mcp.server import mcp as image_sorcery_mcp_server\n\n\n@pytest.fixture\ndef mcp_server():\n    # Use the existing server instance\n    return image_sorcery_mcp_server\n\n\n@pytest.mark.asyncio\nasync def test_list_tools(mcp_server: FastMCP):\n    \"\"\"Tests listing available tools.\"\"\"\n    async with Client(mcp_server) as client:\n        tools = await client.list_tools()  # Correctly list tools using the client\n\n        # Verify that tools list is not empty\n        assert tools, \"Tools list should not be empty\"\n        assert len(tools) > 0, \"Tools list should contain at least one tool\"\n\n\n@pytest.mark.asyncio\nasync def test_nonexisting_tool(mcp_server: FastMCP):\n    \"\"\"Tests calling a non-existent tool.\"\"\"\n    nonexistent_tool_name = \"nonexistent_tool\"\n\n    async with Client(mcp_server) as client:\n        with pytest.raises(Exception) as excinfo:\n            await client.call_tool(nonexistent_tool_name)\n\n        # Check that the error message contains the tool name\n        assert nonexistent_tool_name in str(excinfo.value)\n"
  },
  {
    "path": "tests/test_telemetry.py",
    "content": "\"\"\"\nTests for the telemetry system.\n\"\"\"\n\nimport logging\nimport os\nimport tempfile\nimport uuid\nfrom pathlib import Path\nfrom unittest.mock import patch\n\nfrom imagesorcery_mcp.config import get_config_manager\nfrom imagesorcery_mcp.logging_config import logger as imagesorcery_logger\nfrom imagesorcery_mcp.middlewares.telemetry import TelemetryMiddleware\n\n\n# Mock the awaitable response for call_next\nasync def mock_call_next_func(context):\n    \"\"\"A simple async function to mock the call_next behavior.\"\"\"\n    return \"response\"\n\n# Mock the telemetry handlers to prevent actual network calls during tests\nclass MockAmplitudeHandler:\n    def __init__(self):\n        self.events = []\n\n    def track_event(self, event_data):\n        self.events.append(event_data)\n\nclass MockPostHogHandler:\n    def __init__(self):\n        self.events = []\n\n    def track_event(self, event_data):\n        self.events.append(event_data)\n\n\nclass TestTelemetryMiddleware:\n    \"\"\"Tests for the TelemetryMiddleware.\"\"\"\n\n    def setup_method(self):\n        \"\"\"Set up test environment.\"\"\"\n        self.temp_dir = tempfile.mkdtemp()\n        self.original_cwd = os.getcwd()\n        os.chdir(self.temp_dir)\n\n        # Ensure a config.toml exists for get_config()\n        config_manager = get_config_manager()\n        config_manager._ensure_config_file_exists()\n\n        # Create a .user_id file for testing\n        self.user_id_file = Path(\".user_id\")\n        self.test_user_id = str(uuid.uuid4())\n        self.user_id_file.write_text(self.test_user_id)\n\n        # Reset global config manager to ensure fresh load with temp config\n        import imagesorcery_mcp.config\n        imagesorcery_mcp.config._config_manager = None\n        get_config_manager().reset_runtime_overrides() # Ensure config is reloaded\n\n        # Suppress logging during tests to avoid clutter\n        logging.disable(logging.CRITICAL)\n\n        # Initialize mock handlers for each test run\n        self._mock_amplitude_handler = MockAmplitudeHandler()\n        self._mock_posthog_handler = MockPostHogHandler()\n\n    def teardown_method(self):\n        \"\"\"Clean up test environment.\"\"\"\n        logging.disable(logging.NOTSET) # Re-enable logging\n        os.chdir(self.original_cwd)\n        import shutil\n        shutil.rmtree(self.temp_dir)\n\n        # Reset global config manager again for other tests\n        import imagesorcery_mcp.config\n        imagesorcery_mcp.config._config_manager = None\n\n    def test_middleware_initialization(self):\n        \"\"\"Test that TelemetryMiddleware can be initialized.\"\"\"\n        # Patch the module-level handlers during initialization\n        with patch('imagesorcery_mcp.middlewares.telemetry.amplitude_handler', new=self._mock_amplitude_handler), \\\n             patch('imagesorcery_mcp.middlewares.telemetry.posthog_handler', new=self._mock_posthog_handler):\n            middleware = TelemetryMiddleware(logger=imagesorcery_logger)\n            assert isinstance(middleware, TelemetryMiddleware)\n            assert middleware.user_id == self.test_user_id\n            assert middleware.version != \"unknown\"  # Should get a version from pyproject.toml\n            assert middleware.system is not None\n\n    async def test_telemetry_tracking_enabled_and_disabled(self):\n        \"\"\"Test that telemetry events are tracked when enabled and not tracked when disabled.\"\"\"\n        # Patch the module-level handlers for this test\n        with patch('imagesorcery_mcp.middlewares.telemetry.amplitude_handler', new=self._mock_amplitude_handler), \\\n             patch('imagesorcery_mcp.middlewares.telemetry.posthog_handler', new=self._mock_posthog_handler):\n\n            middleware = TelemetryMiddleware(logger=imagesorcery_logger)\n            config_manager = get_config_manager()\n\n            # 1. Test when telemetry is DISABLED (default)\n            config_manager.update_config({\"telemetry.enabled\": False}, persist=True)\n            await middleware.on_call_tool(\n                context=type(\"MockContext\", (object,), {\"message\": type(\"MockMessage\", (object,), {\"name\": \"test_tool\"})})(),\n                call_next=mock_call_next_func\n            )\n            assert len(self._mock_amplitude_handler.events) == 0\n            assert len(self._mock_posthog_handler.events) == 0\n\n            # 2. Test when telemetry is ENABLED\n            config_manager.update_config({\"telemetry.enabled\": True}, persist=True)\n            await middleware.on_call_tool(\n                context=type(\"MockContext\", (object,), {\"message\": type(\"MockMessage\", (object,), {\"name\": \"test_tool\"})})(),\n                call_next=mock_call_next_func\n            )\n            assert len(self._mock_amplitude_handler.events) == 1\n            assert len(self._mock_posthog_handler.events) == 1\n            \n            # Verify event data\n            amplitude_event = self._mock_amplitude_handler.events[0]\n            posthog_event = self._mock_posthog_handler.events[0]\n\n            assert amplitude_event[\"user_id\"] == self.test_user_id\n            assert amplitude_event[\"action_type\"] == \"calling_tool\"\n            assert amplitude_event[\"identifier\"] == \"test_tool\"\n            assert amplitude_event[\"status\"] == \"success\"\n\n            assert posthog_event[\"user_id\"] == self.test_user_id\n            assert posthog_event[\"action_type\"] == \"calling_tool\"\n            assert posthog_event[\"identifier\"] == \"test_tool\"\n            assert posthog_event[\"status\"] == \"success\"\n\n            # 3. Test when telemetry is DISABLED again\n            config_manager.update_config({\"telemetry.enabled\": False}, persist=True)\n            self._mock_amplitude_handler.events = [] # Clear previous events\n            self._mock_posthog_handler.events = []\n            await middleware.on_call_tool(\n                context=type(\"MockContext\", (object,), {\"message\": type(\"MockMessage\", (object,), {\"name\": \"another_tool\"})})(),\n                call_next=mock_call_next_func\n            )\n            assert len(self._mock_amplitude_handler.events) == 0\n            assert len(self._mock_posthog_handler.events) == 0\n"
  },
  {
    "path": "tests/tools/test_blur.py",
    "content": "import os\n\nimport cv2\nimport numpy as np\nimport pytest\nfrom fastmcp import Client, FastMCP\n\nfrom imagesorcery_mcp.server import mcp as image_sorcery_mcp_server\n\n\n@pytest.fixture\ndef mcp_server():\n    # Use the existing server instance\n    return image_sorcery_mcp_server\n\n\n@pytest.fixture\ndef test_image_path(tmp_path):\n    \"\"\"Create a test image with a checkerboard pattern for blurring.\"\"\"\n    img_path = tmp_path / \"test_image.png\"\n    \n    # Create a white image\n    img = np.ones((300, 400, 3), dtype=np.uint8) * 255\n    \n    # Create a checkerboard pattern in the center area\n    square_size = 20  # Size of each square in the checkerboard\n    for i in range(5):  # 5x5 checkerboard\n        for j in range(5):\n            if (i + j) % 2 == 0:  # Alternate black and white\n                x1 = 150 + j * square_size\n                y1 = 100 + i * square_size\n                x2 = x1 + square_size\n                y2 = y1 + square_size\n                cv2.rectangle(img, (x1, y1), (x2, y2), (0, 0, 0), -1)  # Black square\n    \n    cv2.imwrite(str(img_path), img)\n    return str(img_path)\n\n@pytest.fixture\ndef test_image_for_invert_blur(tmp_path):\n    \"\"\"Create a test image with a noisy background and a solid central object for invert_areas blurring.\"\"\"\n    img_path = tmp_path / \"test_image_invert_blur.png\"\n    \n    # Create a noisy background\n    img = np.random.randint(0, 256, (300, 400, 3), dtype=np.uint8)\n    \n    # Create a checkerboard pattern in the center area (the area to be kept unblurred)\n    square_size = 20  # Size of each square in the checkerboard\n    center_x_start = 150\n    center_y_start = 100\n    for i in range(5):  # 5x5 checkerboard\n        for j in range(5):\n            if (i + j) % 2 == 0:  # Alternate black and white\n                x1 = center_x_start + j * square_size\n                y1 = center_y_start + i * square_size\n                x2 = x1 + square_size\n                y2 = y1 + square_size\n                cv2.rectangle(img, (x1, y1), (x2, y2), (0, 0, 0), -1)  # Black square\n            else:\n                x1 = center_x_start + j * square_size\n                y1 = center_y_start + i * square_size\n                x2 = x1 + square_size\n                y2 = y1 + square_size\n                cv2.rectangle(img, (x1, y1), (x2, y2), (255, 255, 255), -1) # White square\n    \n    cv2.imwrite(str(img_path), img)\n    return str(img_path)\n\nclass TestBlurToolDefinition:\n    \"\"\"Tests for the blur tool definition and metadata.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_blur_in_tools_list(self, mcp_server: FastMCP):\n        \"\"\"Tests that blur tool is in the list of available tools.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            # Verify that tools list is not empty\n            assert tools, \"Tools list should not be empty\"\n\n            # Check if blur is in the list of tools\n            tool_names = [tool.name for tool in tools]\n            assert \"blur\" in tool_names, (\n                \"blur tool should be in the list of available tools\"\n            )\n\n    @pytest.mark.asyncio\n    async def test_blur_description(self, mcp_server: FastMCP):\n        \"\"\"Tests that blur tool has the correct description.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            blur_tool = next((tool for tool in tools if tool.name == \"blur\"), None)\n\n            # Check description\n            assert blur_tool.description, \"blur tool should have a description\"\n            assert \"blur\" in blur_tool.description.lower(), (\n                \"Description should mention that it blurs areas of an image\"\n            )\n\n    @pytest.mark.asyncio\n    async def test_blur_parameters(self, mcp_server: FastMCP):\n        \"\"\"Tests that blur tool has the correct parameter structure.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            blur_tool = next((tool for tool in tools if tool.name == \"blur\"), None)\n\n            # Check input schema\n            assert hasattr(blur_tool, \"inputSchema\"), (\n                \"blur tool should have an inputSchema\"\n            )\n            assert \"properties\" in blur_tool.inputSchema, (\n                \"inputSchema should have properties field\"\n            )\n\n            # Check required parameters\n            required_params = [\"input_path\", \"areas\"]\n            for param in required_params:\n                assert param in blur_tool.inputSchema[\"properties\"], (\n                    f\"blur tool should have a '{param}' property in its inputSchema\"\n                )\n\n            # Check optional parameters\n            assert \"output_path\" in blur_tool.inputSchema[\"properties\"], (\n                \"blur tool should have an 'output_path' property in its inputSchema\"\n            )\n\n            # Check parameter types\n            assert (\n                blur_tool.inputSchema[\"properties\"][\"input_path\"].get(\"type\")\n                == \"string\"\n            ), \"input_path should be of type string\"\n            assert (\n                blur_tool.inputSchema[\"properties\"][\"areas\"].get(\"type\")\n                == \"array\"\n            ), \"areas should be of type array\"\n            \n            # Check output_path type - it can be string or null since it's optional\n            output_path_schema = blur_tool.inputSchema[\"properties\"][\"output_path\"]\n            assert \"anyOf\" in output_path_schema, \"output_path should have anyOf field for optional types\"\n            \n            # Check that string is one of the allowed types\n            string_type_present = any(\n                type_option.get(\"type\") == \"string\" \n                for type_option in output_path_schema[\"anyOf\"]\n            )\n            assert string_type_present, \"output_path should allow string type\"\n\n\nclass TestBlurToolExecution:\n    \"\"\"Tests for the blur tool execution and results.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_blur_tool_execution(\n        self, mcp_server: FastMCP, test_image_path, tmp_path\n    ):\n        \"\"\"Tests the blur tool execution and return value.\"\"\"\n        output_path = str(tmp_path / \"output.png\")\n        \n        # Define the area to blur - covering the checkerboard pattern\n        blur_area = {\n            \"x1\": 150,\n            \"y1\": 100,\n            \"x2\": 250,\n            \"y2\": 200,\n            \"blur_strength\": 21\n        }\n        \n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"blur\",\n                {\n                    \"input_path\": test_image_path,\n                    \"areas\": [blur_area],\n                    \"output_path\": output_path,\n                },\n            )\n\n            # Check that the tool returned a result\n            assert result.data == output_path\n\n            # Verify the file exists\n            assert os.path.exists(output_path)\n\n    @pytest.mark.asyncio\n    async def test_blur_invert_rectangle(self, mcp_server: FastMCP, test_image_for_invert_blur, tmp_path):\n        \"\"\"Tests the blur tool with invert_areas for a rectangle.\"\"\"\n        output_path = str(tmp_path / \"output_inverted.png\")\n        \n        # Define a rectangle in the center (the solid black area to be kept unblurred)\n        blur_area = {\"x1\": 150, \"y1\": 100, \"x2\": 250, \"y2\": 200, \"blur_strength\": 21}\n        \n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"blur\", \n                {\n                    \"input_path\": test_image_for_invert_blur, \n                    \"areas\": [blur_area], \n                    \"invert_areas\": True,\n                    \"output_path\": output_path\n                }\n            )\n            assert result.data == output_path\n            assert os.path.exists(output_path)\n\n            img = cv2.imread(output_path)\n            original_img = cv2.imread(test_image_for_invert_blur)\n            \n            # Center pixel (inside the specified area) should NOT be blurred - remains original\n            center_pixel_original = original_img[150, 200]\n            center_pixel_blurred = img[150, 200]\n            assert np.array_equal(center_pixel_original, center_pixel_blurred)\n            \n            # Pixels outside the area (noisy background) should be blurred\n            outside_pixel_original = original_img[50, 50]\n            outside_pixel_blurred = img[50, 50]\n            assert not np.array_equal(outside_pixel_original, outside_pixel_blurred)\n            assert np.std(outside_pixel_blurred) < np.std(outside_pixel_original)\n\n    @pytest.mark.asyncio\n    async def test_blur_invert_polygon(self, mcp_server: FastMCP, test_image_for_invert_blur, tmp_path):\n        \"\"\"Tests the blur tool with invert_areas for a polygon.\"\"\"\n        output_path = str(tmp_path / \"output_inverted_poly.png\")\n        \n        # Define a triangle polygon within the central object area\n        polygon_area = {\"polygon\": [[160, 110], [240, 110], [200, 190]], \"blur_strength\": 21}\n        \n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"blur\",\n                {\n                    \"input_path\": test_image_for_invert_blur,\n                    \"areas\": [polygon_area],\n                    \"invert_areas\": True,\n                    \"output_path\": output_path\n                }\n            )\n            assert result.data == output_path\n            assert os.path.exists(output_path)\n\n            img = cv2.imread(output_path)\n            original_img = cv2.imread(test_image_for_invert_blur)\n            \n            # Center of polygon (inside the specified area) should NOT be blurred\n            poly_center_original = original_img[150, 200]\n            poly_center_blurred = img[150, 200]\n            assert np.array_equal(poly_center_original, poly_center_blurred)\n            \n            # Outside pixels (noisy background) should be blurred\n            outside_pixel_original = original_img[50, 50]\n            outside_pixel_blurred = img[50, 50]\n            assert not np.array_equal(outside_pixel_original, outside_pixel_blurred)\n            assert np.std(outside_pixel_blurred) < np.std(outside_pixel_original)\n\n    @pytest.mark.asyncio\n    async def test_blur_invert_multiple_areas(self, mcp_server: FastMCP, test_image_for_invert_blur, tmp_path):\n        \"\"\"Tests invert_areas with multiple areas to keep unblurred.\"\"\"\n        output_path = str(tmp_path / \"output_multi_unblurred.png\")\n        \n        # Keep two areas unblurred (within the central object), blur everything else\n        areas = [\n            {\"x1\": 160, \"y1\": 110, \"x2\": 190, \"y2\": 140, \"blur_strength\": 11},\n            {\"x1\": 210, \"y1\": 160, \"x2\": 240, \"y2\": 190, \"blur_strength\": 21}\n        ]\n        \n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"blur\",\n                {\n                    \"input_path\": test_image_for_invert_blur,\n                    \"areas\": areas,\n                    \"invert_areas\": True,\n                    \"output_path\": output_path\n                }\n            )\n            assert result.data == output_path\n            assert os.path.exists(output_path)\n\n            img = cv2.imread(output_path)\n            original_img = cv2.imread(test_image_for_invert_blur)\n            \n            # First kept area should NOT be blurred (remains original)\n            kept_pixel1_original = original_img[125, 175]\n            kept_pixel1_blurred = img[125, 175]\n            assert np.array_equal(kept_pixel1_original, kept_pixel1_blurred)\n            \n            # Second kept area should NOT be blurred (remains original)\n            kept_pixel2_original = original_img[175, 225]\n            kept_pixel2_blurred = img[175, 225]\n            assert np.array_equal(kept_pixel2_original, kept_pixel2_blurred)\n            \n            # Area between them (noisy background) should be blurred\n            between_pixel_original = original_img[50, 50]\n            between_pixel_blurred = img[50, 50]\n            assert not np.array_equal(between_pixel_original, between_pixel_blurred)\n            assert np.std(between_pixel_blurred) < np.std(between_pixel_original)\n\n    @pytest.mark.asyncio\n    async def test_blur_polygon_area(self, mcp_server: FastMCP, test_image_path, tmp_path):\n        \"\"\"Tests the blur tool with a polygon area.\"\"\"\n        output_path = str(tmp_path / \"output_poly.png\")\n\n        # Define a triangular polygon within the checkerboard area\n        polygon_area = {\n            \"polygon\": [[160, 110], [240, 110], [200, 190]],\n            \"blur_strength\": 21\n        }\n\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"blur\",\n                {\n                    \"input_path\": test_image_path,\n                    \"areas\": [polygon_area],\n                    \"output_path\": output_path,\n                },\n            )\n\n            # Check that the tool returned a result\n            assert result.data == output_path\n\n            # Verify the file exists\n            assert os.path.exists(output_path)\n\n            # Verify the image was created with correct dimensions\n            img = cv2.imread(output_path)\n            assert img.shape[:2] == (300, 400)  # height, width\n\n            # Verify that the blurred area has different pixel values than the original\n            original_img = cv2.imread(test_image_path)\n\n            # Create a mask of the polygon to check pixels\n            mask = np.zeros(img.shape[:2], dtype=np.uint8)\n            cv2.fillPoly(mask, [np.array(polygon_area[\"polygon\"], dtype=np.int32)], 255)\n\n            # Get pixels from original and blurred images using the mask\n            original_pixels = original_img[mask == 255]\n            blurred_pixels = img[mask == 255]\n\n            # The pixels should be different\n            assert not np.array_equal(original_pixels, blurred_pixels)\n\n            # The standard deviation of the blurred pixels should be lower\n            # because the checkerboard pattern is being smoothed\n            assert np.std(blurred_pixels) < np.std(original_pixels)\n\n    @pytest.mark.asyncio\n    async def test_blur_mixed_areas(self, mcp_server: FastMCP, test_image_path, tmp_path):\n        \"\"\"Tests the blur tool with a mix of rectangle and polygon areas.\"\"\"\n        output_path = str(tmp_path / \"output_mixed.png\")\n\n        # Define areas\n        rect_area = {\"x1\": 150, \"y1\": 100, \"x2\": 250, \"y2\": 200, \"blur_strength\": 11}\n        poly_area = {\"polygon\": [[160, 110], [240, 110], [200, 190]], \"blur_strength\": 21}\n\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"blur\",\n                {\n                    \"input_path\": test_image_path,\n                    \"areas\": [rect_area, poly_area],\n                    \"output_path\": output_path,\n                },\n            )\n\n            assert result.data == output_path\n            assert os.path.exists(output_path)\n\n            img = cv2.imread(output_path)\n            original_img = cv2.imread(test_image_path)\n\n            # Check rectangle blur by comparing regions\n            blurred_rect_region = img[rect_area[\"y1\"]:rect_area[\"y2\"], rect_area[\"x1\"]:rect_area[\"x2\"]]\n            original_rect_region = original_img[rect_area[\"y1\"]:rect_area[\"y2\"], rect_area[\"x1\"]:rect_area[\"x2\"]]\n            assert not np.array_equal(blurred_rect_region, original_rect_region)\n            assert np.std(blurred_rect_region) < np.std(original_rect_region)\n\n            # Check polygon blur by checking a point inside\n            # Create a mask for the polygon to check pixels\n            mask = np.zeros(img.shape[:2], dtype=np.uint8)\n            cv2.fillPoly(mask, [np.array(poly_area[\"polygon\"], dtype=np.int32)], 255)\n            original_poly_pixels = original_img[mask == 255]\n            blurred_poly_pixels = img[mask == 255]\n            assert not np.array_equal(original_poly_pixels, blurred_poly_pixels)\n            assert np.std(blurred_poly_pixels) < np.std(original_poly_pixels)\n\n            # Verify the image was created with correct dimensions\n            assert img.shape[:2] == (300, 400)  # height, width\n\n    @pytest.mark.asyncio\n    async def test_blur_default_output_path(self, mcp_server: FastMCP, test_image_path):\n        \"\"\"Tests the blur tool with default output path.\"\"\"\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"blur\",\n                {\n                    \"input_path\": test_image_path,\n                    \"areas\": [\n                        {\n                            \"x1\": 150,\n                            \"y1\": 100,\n                            \"x2\": 250,\n                            \"y2\": 200,\n                        }\n                    ]\n                },\n            )\n\n            # Check that the tool returned a result\n            expected_output = test_image_path.replace(\".png\", \"_blurred.png\")\n            assert result.data == expected_output\n\n            # Verify the file exists\n            assert os.path.exists(expected_output)\n\n    @pytest.mark.asyncio\n    async def test_blur_multiple_areas(self, mcp_server: FastMCP, test_image_path, tmp_path):\n        \"\"\"Tests the blur tool with multiple areas.\"\"\"\n        output_path = str(tmp_path / \"multi_blur.png\")\n        \n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"blur\",\n                {\n                    \"input_path\": test_image_path,\n                    \"areas\": [\n                        {\n                            \"x1\": 50,\n                            \"y1\": 50,\n                            \"x2\": 100,\n                            \"y2\": 100,\n                            \"blur_strength\": 11\n                        },\n                        {\n                            \"x1\": 150,\n                            \"y1\": 100,\n                            \"x2\": 250,\n                            \"y2\": 200,\n                            \"blur_strength\": 21\n                        }\n                    ],\n                    \"output_path\": output_path\n                },\n            )\n\n            # Check that the tool returned a result\n            assert result.data == output_path\n\n            # Verify the file exists\n            assert os.path.exists(output_path)\n"
  },
  {
    "path": "tests/tools/test_change_color.py",
    "content": "import os\n\nimport cv2\nimport numpy as np\nimport pytest\nfrom fastmcp import Client, FastMCP\n\nfrom imagesorcery_mcp.server import mcp as image_sorcery_mcp_server\n\n\n@pytest.fixture\ndef mcp_server():\n    # Use the existing server instance\n    return image_sorcery_mcp_server\n\n\n@pytest.fixture\ndef test_image_path(tmp_path):\n    \"\"\"Create a colorful test image.\"\"\"\n    img_path = tmp_path / \"test_color_image.png\"\n    img = np.zeros((100, 100, 3), dtype=np.uint8)\n    # Add some colors\n    img[0:50, 0:50] = [255, 0, 0]  # Blue\n    img[0:50, 50:100] = [0, 255, 0]  # Green\n    img[50:100, 0:50] = [0, 0, 255]  # Red\n    img[50:100, 50:100] = [255, 255, 0]  # Cyan\n    cv2.imwrite(str(img_path), img)\n    return str(img_path)\n\n\nclass TestChangeColorToolDefinition:\n    \"\"\"Tests for the change_color tool definition and metadata.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_change_color_in_tools_list(self, mcp_server: FastMCP):\n        \"\"\"Tests that change_color tool is in the list of available tools.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            assert tools, \"Tools list should not be empty\"\n            tool_names = [tool.name for tool in tools]\n            assert \"change_color\" in tool_names, \"change_color tool should be in the list of available tools\"\n\n    @pytest.mark.asyncio\n    async def test_change_color_description(self, mcp_server: FastMCP):\n        \"\"\"Tests that change_color tool has the correct description.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            change_color_tool = next((tool for tool in tools if tool.name == \"change_color\"), None)\n            assert change_color_tool.description, \"change_color tool should have a description\"\n            assert \"color palette\" in change_color_tool.description.lower(), \"Description should mention changing the color palette\"\n\n    @pytest.mark.asyncio\n    async def test_change_color_parameters(self, mcp_server: FastMCP):\n        \"\"\"Tests that change_color tool has the correct parameter structure.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            change_color_tool = next((tool for tool in tools if tool.name == \"change_color\"), None)\n\n            assert hasattr(change_color_tool, \"inputSchema\"), \"change_color tool should have an inputSchema\"\n            assert \"properties\" in change_color_tool.inputSchema, \"inputSchema should have properties field\"\n\n            required_params = [\"input_path\", \"palette\"]\n            for param in required_params:\n                assert param in change_color_tool.inputSchema[\"properties\"], f\"change_color tool should have a '{param}' property in its inputSchema\"\n\n            assert \"output_path\" in change_color_tool.inputSchema[\"properties\"], \"change_color tool should have an 'output_path' property in its inputSchema\"\n\n            assert change_color_tool.inputSchema[\"properties\"][\"input_path\"].get(\"type\") == \"string\"\n            assert change_color_tool.inputSchema[\"properties\"][\"palette\"].get(\"type\") == \"string\"\n\n            output_path_schema = change_color_tool.inputSchema[\"properties\"][\"output_path\"]\n            assert \"anyOf\" in output_path_schema\n            string_type_present = any(type_option.get(\"type\") == \"string\" for type_option in output_path_schema[\"anyOf\"])\n            assert string_type_present\n\n\nclass TestChangeColorToolExecution:\n    \"\"\"Tests for the change_color tool execution and results.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_change_color_grayscale(self, mcp_server: FastMCP, test_image_path, tmp_path):\n        \"\"\"Tests the change_color tool with the 'grayscale' palette.\"\"\"\n        output_path = str(tmp_path / \"output_grayscale.png\")\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"change_color\",\n                {\"input_path\": test_image_path, \"palette\": \"grayscale\", \"output_path\": output_path},\n            )\n            assert result.data == output_path\n            assert os.path.exists(output_path)\n\n            img = cv2.imread(output_path, cv2.IMREAD_UNCHANGED)\n            assert len(img.shape) == 2, \"Grayscale image should have 2 dimensions (height, width)\"\n            \n            # Check a pixel from the original blue area\n            # Original blue: [255, 0, 0] -> BGR\n            # Grayscale conversion: Y = 0.299*R + 0.587*G + 0.114*B = 0.114*255 = 29.07\n            # Expected grayscale value: ~29\n            pixel_value = img[25, 25]\n            assert np.isclose(pixel_value, 29, atol=2), f\"Pixel value {pixel_value} is not close to expected grayscale value for blue\"\n            \n            # Check a pixel from the original green area\n            # Original green: [0, 255, 0] -> BGR\n            # Grayscale conversion: Y = 0.299*R + 0.587*G + 0.114*B = 0.587*255 = 149.69\n            # Expected grayscale value: ~150\n            pixel_value = img[25, 75]\n            assert np.isclose(pixel_value, 150, atol=2), f\"Pixel value {pixel_value} is not close to expected grayscale value for green\"\n    @pytest.mark.asyncio\n    async def test_change_color_sepia(self, mcp_server: FastMCP, test_image_path, tmp_path):\n        \"\"\"Tests the change_color tool with the 'sepia' palette.\"\"\"\n        output_path = str(tmp_path / \"output_sepia.png\")\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"change_color\",\n                {\"input_path\": test_image_path, \"palette\": \"sepia\", \"output_path\": output_path},\n            )\n            assert result.data == output_path\n            assert os.path.exists(output_path)\n\n            img = cv2.imread(output_path)\n            assert len(img.shape) == 3, \"Sepia image should have 3 dimensions\"\n\n            # Check a pixel from the original blue area\n            # Original blue: [255, 0, 0] -> BGR\n            # Sepia transform: B' = 0.272*B + 0.534*G + 0.131*R = 0.272*255 = 69.36\n            #                  G' = 0.349*B + 0.686*G + 0.168*R = 0.349*255 = 88.99\n            #                  R' = 0.393*B + 0.769*G + 0.189*R = 0.393*255 = 100.21\n            # Expected BGR: [69, 89, 100]\n            pixel = img[25, 25]\n            assert np.allclose(pixel, [69, 89, 100], atol=2), f\"Pixel {pixel} is not close to sepia-toned blue\"\n\n    @pytest.mark.asyncio\n    async def test_change_color_default_output_path(self, mcp_server: FastMCP, test_image_path):\n        \"\"\"Tests the change_color tool with a default output path.\"\"\"\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\"change_color\", {\"input_path\": test_image_path, \"palette\": \"grayscale\"})\n            expected_output = test_image_path.replace(\".png\", \"_grayscale.png\")\n            assert result.data == expected_output\n            assert os.path.exists(expected_output)\n\n    @pytest.mark.asyncio\n    async def test_change_color_invalid_palette(self, mcp_server: FastMCP, test_image_path):\n        \"\"\"Tests the change_color tool with an invalid palette.\"\"\"\n        async with Client(mcp_server) as client:\n            with pytest.raises(Exception) as excinfo:\n                await client.call_tool(\"change_color\", {\"input_path\": test_image_path, \"palette\": \"invalid_palette\"})\n        assert \"input validation error\" in str(excinfo.value).lower()\n"
  },
  {
    "path": "tests/tools/test_config_tool.py",
    "content": "\"\"\"\nEnd-to-end tests for the config tool through MCP client interface.\n\"\"\"\n\nimport os\nimport tempfile\nfrom pathlib import Path\n\nimport pytest\nimport toml\nfrom fastmcp import Client\nfrom fastmcp.exceptions import ToolError\n\nfrom imagesorcery_mcp.server import mcp\n\n\nclass TestConfigToolE2E:\n    \"\"\"End-to-end tests for the config tool through MCP client.\"\"\"\n\n    def setup_method(self):\n        \"\"\"Set up test environment.\"\"\"\n        self.temp_dir = tempfile.mkdtemp()\n        self.original_cwd = os.getcwd()\n        os.chdir(self.temp_dir)\n\n    def teardown_method(self):\n        \"\"\"Clean up test environment.\"\"\"\n        os.chdir(self.original_cwd)\n        import shutil\n        shutil.rmtree(self.temp_dir)\n\n        # Reset global config manager\n        import imagesorcery_mcp.config\n        imagesorcery_mcp.config._config_manager = None\n\n    @pytest.mark.asyncio\n    async def test_config_tool_registration(self):\n        \"\"\"Test that config tool is properly registered in the server.\"\"\"\n        async with Client(mcp) as client:\n            tools = await client.list_tools()\n            config_tool = next((tool for tool in tools if tool.name == \"config\"), None)\n\n            assert config_tool is not None, \"Config tool should be registered\"\n            assert config_tool.name == \"config\"\n\n            # Check input schema has required parameters\n            schema = config_tool.inputSchema\n            assert \"properties\" in schema\n            assert \"action\" in schema[\"properties\"]\n            assert \"key\" in schema[\"properties\"]\n            assert \"value\" in schema[\"properties\"]\n            assert \"persist\" in schema[\"properties\"]\n\n    @pytest.mark.asyncio\n    async def test_config_get_all(self):\n        \"\"\"Test getting entire configuration through MCP client.\"\"\"\n        async with Client(mcp) as client:\n            # Call config tool to get all configuration\n            result = await client.call_tool(\"config\", {\"action\": \"get\"})\n\n            assert result.is_error is False, f\"Config tool should not error: {result.content}\"\n\n            # Parse the result content\n            content = result.content[0].text\n            assert \"action\" in content\n            assert \"config\" in content\n            assert \"runtime_overrides\" in content\n\n            # Verify it contains expected configuration sections\n            assert \"detection\" in content\n            assert \"blur\" in content\n            assert \"text\" in content\n\n    @pytest.mark.asyncio\n    async def test_config_get_specific_key(self):\n        \"\"\"Test getting specific configuration value through MCP client.\"\"\"\n        async with Client(mcp) as client:\n            # Call config tool to get specific key\n            result = await client.call_tool(\"config\", {\n                \"action\": \"get\",\n                \"key\": \"detection.confidence_threshold\"\n            })\n\n            assert result.is_error is False, f\"Config tool should not error: {result.content}\"\n\n            content = result.content[0].text\n            assert \"action\" in content\n            assert \"key\" in content\n            assert \"value\" in content\n            assert \"detection.confidence_threshold\" in content\n\n    @pytest.mark.asyncio\n    async def test_config_set_runtime(self):\n        \"\"\"Test setting configuration value for runtime only through MCP client.\"\"\"\n        async with Client(mcp) as client:\n            # Set a runtime configuration value\n            result = await client.call_tool(\"config\", {\n                \"action\": \"set\",\n                \"key\": \"detection.confidence_threshold\",\n                \"value\": 0.8,\n                \"persist\": False\n            })\n\n            assert result.is_error is False, f\"Config tool should not error: {result.content}\"\n\n            content = result.content[0].text\n            assert \"action\" in content\n            assert \"set\" in content\n            assert \"detection.confidence_threshold\" in content\n            assert \"0.8\" in content\n            assert \"current session\" in content\n\n            # Verify the change by getting the value back\n            get_result = await client.call_tool(\"config\", {\n                \"action\": \"get\",\n                \"key\": \"detection.confidence_threshold\"\n            })\n\n            assert get_result.is_error is False\n            get_content = get_result.content[0].text\n            assert \"0.8\" in get_content\n\n    @pytest.mark.asyncio\n    async def test_config_set_persistent(self):\n        \"\"\"Test setting configuration value persistently through MCP client.\"\"\"\n        async with Client(mcp) as client:\n            # Set a persistent configuration value\n            result = await client.call_tool(\"config\", {\n                \"action\": \"set\",\n                \"key\": \"blur.strength\",\n                \"value\": 21,\n                \"persist\": True\n            })\n\n            assert result.is_error is False, f\"Config tool should not error: {result.content}\"\n\n            content = result.content[0].text\n            assert \"action\" in content\n            assert \"set\" in content\n            assert \"blur.strength\" in content\n            assert \"21\" in content\n            assert \"persisted to file\" in content\n\n            # Verify the config file was updated\n            assert Path(\"config.toml\").exists()\n            with open(\"config.toml\", \"r\") as f:\n                config_data = toml.load(f)\n            assert config_data[\"blur\"][\"strength\"] == 21\n\n    @pytest.mark.asyncio\n    async def test_config_set_invalid_value(self):\n        \"\"\"Test setting invalid configuration value through MCP client.\"\"\"\n        async with Client(mcp) as client:\n            # Try to set an invalid confidence threshold\n            result = await client.call_tool(\"config\", {\n                \"action\": \"set\",\n                \"key\": \"detection.confidence_threshold\",\n                \"value\": 1.5  # Invalid: > 1.0\n            })\n\n            assert result.is_error is False  # Tool doesn't error, but returns error in content\n\n            content = result.content[0].text\n            assert \"error\" in content\n            assert \"Invalid configuration update\" in content\n\n    @pytest.mark.asyncio\n    async def test_config_reset(self):\n        \"\"\"Test resetting runtime configuration overrides through MCP client.\"\"\"\n        async with Client(mcp) as client:\n            # First set some runtime values\n            await client.call_tool(\"config\", {\n                \"action\": \"set\",\n                \"key\": \"detection.confidence_threshold\",\n                \"value\": 0.9,\n                \"persist\": False\n            })\n\n            await client.call_tool(\"config\", {\n                \"action\": \"set\",\n                \"key\": \"text.font_scale\",\n                \"value\": 2.0,\n                \"persist\": False\n            })\n\n            # Reset runtime overrides\n            result = await client.call_tool(\"config\", {\"action\": \"reset\"})\n\n            assert result.is_error is False, f\"Config tool should not error: {result.content}\"\n\n            content = result.content[0].text\n            assert \"action\" in content\n            assert \"reset\" in content\n            assert \"Runtime configuration overrides reset successfully\" in content\n\n            # Verify values are back to defaults\n            get_result = await client.call_tool(\"config\", {\n                \"action\": \"get\",\n                \"key\": \"detection.confidence_threshold\"\n            })\n\n            get_content = get_result.content[0].text\n            assert \"0.75\" in get_content  # Back to default\n\n    @pytest.mark.asyncio\n    async def test_config_get_nonexistent_key(self):\n        \"\"\"Test getting non-existent configuration key through MCP client.\"\"\"\n        async with Client(mcp) as client:\n            result = await client.call_tool(\"config\", {\n                \"action\": \"get\",\n                \"key\": \"nonexistent.key\"\n            })\n\n            assert result.is_error is False  # Tool doesn't error, but returns error in content\n\n            content = result.content[0].text\n            assert \"error\" in content\n            assert \"Configuration key 'nonexistent.key' not found\" in content\n            assert \"available_keys\" in content\n\n    @pytest.mark.asyncio\n    async def test_config_invalid_action(self):\n        \"\"\"Test config tool with invalid action through MCP client.\"\"\"\n        async with Client(mcp) as client:\n            # Invalid action should raise ToolError due to input validation\n            with pytest.raises(ToolError) as exc_info:\n                await client.call_tool(\"config\", {\"action\": \"invalid\"})\n\n            assert \"Input validation error\" in str(exc_info.value)\n            assert \"invalid\" in str(exc_info.value)\n\n    @pytest.mark.asyncio\n    async def test_config_set_missing_parameters(self):\n        \"\"\"Test config tool with missing required parameters through MCP client.\"\"\"\n        async with Client(mcp) as client:\n            # Test setting without key\n            result = await client.call_tool(\"config\", {\n                \"action\": \"set\",\n                \"value\": 0.8\n            })\n\n            assert result.is_error is False  # Tool doesn't error, but returns error in content\n\n            content = result.content[0].text\n            assert \"error\" in content\n            assert \"Key is required for 'set' action\" in content\n"
  },
  {
    "path": "tests/tools/test_crop.py",
    "content": "import os\n\nimport cv2\nimport numpy as np\nimport pytest\nfrom fastmcp import Client, FastMCP\n\nfrom imagesorcery_mcp.server import mcp as image_sorcery_mcp_server\n\n\n@pytest.fixture\ndef mcp_server():\n    # Use the existing server instance\n    return image_sorcery_mcp_server\n\n\n@pytest.fixture\ndef test_image_path(tmp_path):\n    \"\"\"Create a test image for cropping.\"\"\"\n    img_path = tmp_path / \"test_image.png\"\n    # Create a white image\n    img = np.ones((200, 200, 3), dtype=np.uint8) * 255\n\n    # Draw some colored areas to verify cropping\n    # Red square (50,50) to (100,100)\n    img[50:100, 50:100] = [0, 0, 255]  # OpenCV uses BGR\n\n    # Blue square (100,100) to (150,150)\n    img[100:150, 100:150] = [255, 0, 0]  # OpenCV uses BGR\n\n    cv2.imwrite(str(img_path), img)\n    return str(img_path)\n\n\nclass TestCropToolDefinition:\n    \"\"\"Tests for the crop tool definition and metadata.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_crop_in_tools_list(self, mcp_server: FastMCP):\n        \"\"\"Tests that crop tool is in the list of available tools.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            # Verify that tools list is not empty\n            assert tools, \"Tools list should not be empty\"\n\n            # Check if crop is in the list of tools\n            tool_names = [tool.name for tool in tools]\n            assert \"crop\" in tool_names, (\n                \"crop tool should be in the list of available tools\"\n            )\n\n    @pytest.mark.asyncio\n    async def test_crop_description(self, mcp_server: FastMCP):\n        \"\"\"Tests that crop tool has the correct description.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            crop_tool = next((tool for tool in tools if tool.name == \"crop\"), None)\n\n            # Check description\n            assert crop_tool.description, \"crop tool should have a description\"\n            assert \"crop\" in crop_tool.description.lower(), (\n                \"Description should mention that it crops an image\"\n            )\n\n    @pytest.mark.asyncio\n    async def test_crop_parameters(self, mcp_server: FastMCP):\n        \"\"\"Tests that crop tool has the correct parameter structure.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            crop_tool = next((tool for tool in tools if tool.name == \"crop\"), None)\n\n            # Check input schema\n            assert hasattr(crop_tool, \"inputSchema\"), (\n                \"crop tool should have an inputSchema\"\n            )\n            assert \"properties\" in crop_tool.inputSchema, (\n                \"inputSchema should have properties field\"\n            )\n\n            # Check required parameters\n            required_params = [\"input_path\", \"x1\", \"y1\", \"x2\", \"y2\"]\n            for param in required_params:\n                assert param in crop_tool.inputSchema[\"properties\"], (\n                    f\"crop tool should have a '{param}' property in its inputSchema\"\n                )\n\n            # Check optional parameters\n            assert \"output_path\" in crop_tool.inputSchema[\"properties\"], (\n                \"crop tool should have an 'output_path' property in its inputSchema\"\n            )\n\n            # Check parameter types\n            assert (\n                crop_tool.inputSchema[\"properties\"][\"input_path\"].get(\"type\")\n                == \"string\"\n            ), \"input_path should be of type string\"\n            assert (\n                crop_tool.inputSchema[\"properties\"][\"x1\"].get(\"type\") == \"integer\"\n            ), \"x1 should be of type integer\"\n            assert (\n                crop_tool.inputSchema[\"properties\"][\"y1\"].get(\"type\") == \"integer\"\n            ), \"y1 should be of type integer\"\n            assert (\n                crop_tool.inputSchema[\"properties\"][\"x2\"].get(\"type\") == \"integer\"\n            ), \"x2 should be of type integer\"\n            assert (\n                crop_tool.inputSchema[\"properties\"][\"y2\"].get(\"type\") == \"integer\"\n            ), \"y2 should be of type integer\"\n            assert (\n                crop_tool.inputSchema[\"properties\"][\"output_path\"].get(\"type\")\n                == \"string\"\n            ), \"output_path should be of type string\"\n\n\nclass TestCropToolExecution:\n    \"\"\"Tests for the crop tool execution and results.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_crop_tool_execution(\n        self, mcp_server: FastMCP, test_image_path, tmp_path\n    ):\n        \"\"\"Tests the crop tool execution and return value.\"\"\"\n        output_path = str(tmp_path / \"output.png\")\n\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"crop\",\n                {\n                    \"input_path\": test_image_path,\n                    \"x1\": 50,\n                    \"y1\": 50,\n                    \"x2\": 100,\n                    \"y2\": 100,\n                    \"output_path\": output_path,\n                },\n            )\n\n            # Check that the tool returned a result\n            assert result.data == output_path\n\n            # Verify the file exists\n            assert os.path.exists(output_path)\n\n            # Verify the cropped image dimensions\n            img = cv2.imread(output_path)\n            assert img.shape[:2] == (50, 50)  # height, width\n            # Check if the red square was properly cropped (BGR in OpenCV)\n            assert all(img[0, 0] == [0, 0, 255])  # Red in BGR\n\n    @pytest.mark.asyncio\n    async def test_crop_default_output_path(self, mcp_server: FastMCP, test_image_path):\n        \"\"\"Tests the crop tool with default output path.\"\"\"\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"crop\",\n                {\n                    \"input_path\": test_image_path,\n                    \"x1\": 50,\n                    \"y1\": 50,\n                    \"x2\": 100,\n                    \"y2\": 100,\n                },\n            )\n\n            # Check that the tool returned a result\n            expected_output = test_image_path.replace(\".png\", \"_cropped.png\")\n            assert result.data == expected_output\n\n            # Verify the file exists\n            assert os.path.exists(expected_output)\n"
  },
  {
    "path": "tests/tools/test_detect.py",
    "content": "import os\nimport shutil\n\nimport cv2\nimport numpy as np\nimport pytest\nfrom fastmcp import Client, FastMCP\nfrom PIL import Image, ImageDraw\n\nfrom imagesorcery_mcp.server import mcp as image_sorcery_mcp_server\n\n\n@pytest.fixture\ndef mcp_server():\n    # Use the existing server instance\n    return image_sorcery_mcp_server\n\n\n@pytest.fixture\ndef test_image_path(tmp_path):\n    \"\"\"Path to a test image with known objects for detection.\"\"\"\n    # Path to the test image in the tests/data directory\n    current_dir = os.path.dirname(os.path.abspath(__file__))\n    test_data_dir = os.path.join(os.path.dirname(current_dir), \"data\")\n    src_path = os.path.join(test_data_dir, \"test_detection.jpg\")\n    dest_path = tmp_path / \"test_detection.jpg\"\n    shutil.copy(src_path, dest_path)\n    return str(dest_path)\n\n\n@pytest.fixture\ndef test_image_negative_path(tmp_path):\n    \"\"\"Path to a test image with different objects for negative testing.\"\"\"\n    current_dir = os.path.dirname(os.path.abspath(__file__))\n    test_data_dir = os.path.join(os.path.dirname(current_dir), \"data\")\n    src_path = os.path.join(test_data_dir, \"test_detection_negative.jpg\")\n    dest_path = tmp_path / \"test_detection_negative.jpg\"\n    shutil.copy(src_path, dest_path)\n    return str(dest_path)\n\n\n@pytest.fixture\ndef test_segmentation_image_path(tmp_path):\n    \"\"\"Path to a simple test image for segmentation mask validation.\"\"\"\n    current_dir = os.path.dirname(os.path.abspath(__file__))\n    test_data_dir = os.path.join(os.path.dirname(current_dir), \"data\")\n    src_path = os.path.join(test_data_dir, \"test_detection_mask.jpg\")\n    dest_path = tmp_path / \"test_detection_mask.jpg\"\n    shutil.copy(src_path, dest_path)\n    return str(dest_path)\n\n\nclass TestDetectToolDefinition:\n    \"\"\"Tests for the detect tool definition and metadata.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_detect_in_tools_list(self, mcp_server: FastMCP):\n        \"\"\"Tests that detect tool is in the list of available tools.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            # Verify that tools list is not empty\n            assert tools, \"Tools list should not be empty\"\n\n            # Check if detect is in the list of tools\n            tool_names = [tool.name for tool in tools]\n            assert \"detect\" in tool_names, (\n                \"detect tool should be in the list of available tools\"\n            )\n\n    @pytest.mark.asyncio\n    async def test_detect_description(self, mcp_server: FastMCP):\n        \"\"\"Tests that detect tool has the correct description.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            detect_tool = next((tool for tool in tools if tool.name == \"detect\"), None)\n\n            # Check description\n            assert detect_tool.description, \"detect tool should have a description\"\n            assert \"detect\" in detect_tool.description.lower(), (\n                \"Description should mention that it detects objects in an image\"\n            )\n\n    @pytest.mark.asyncio\n    async def test_detect_parameters(self, mcp_server: FastMCP):\n        \"\"\"Tests that detect tool has the correct parameter structure.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            detect_tool = next((tool for tool in tools if tool.name == \"detect\"), None)\n\n            # Check input schema\n            assert hasattr(detect_tool, \"inputSchema\"), (\n                \"detect tool should have an inputSchema\"\n            )\n            assert \"properties\" in detect_tool.inputSchema, (\n                \"inputSchema should have properties field\"\n            )\n\n            # Check required parameters\n            required_params = [\"input_path\"]\n            for param in required_params:\n                assert param in detect_tool.inputSchema[\"properties\"], (\n                    f\"detect tool should have a '{param}' property in its inputSchema\"\n                )\n\n            # Check optional parameters\n            optional_params = [\"confidence\", \"model_name\", \"return_geometry\", \"geometry_format\"]\n            for param in optional_params:\n                assert param in detect_tool.inputSchema[\"properties\"], (\n                    f\"detect tool should have a '{param}' property in its inputSchema\"\n                )\n\n            # Check parameter types and defaults\n            assert (\n                detect_tool.inputSchema[\"properties\"][\"input_path\"].get(\"type\")\n                == \"string\"\n            ), \"input_path should be of type string\"\n\n            # Check optional parameters (now have anyOf structure with null)\n            confidence_schema = detect_tool.inputSchema[\"properties\"][\"confidence\"]\n            assert \"anyOf\" in confidence_schema, \"confidence should have anyOf structure for optional parameter\"\n            assert any(item.get(\"type\") == \"number\" for item in confidence_schema[\"anyOf\"]), \"confidence should allow number type\"\n            assert any(item.get(\"type\") == \"null\" for item in confidence_schema[\"anyOf\"]), \"confidence should allow null type\"\n\n            model_name_schema = detect_tool.inputSchema[\"properties\"][\"model_name\"]\n            assert \"anyOf\" in model_name_schema, \"model_name should have anyOf structure for optional parameter\"\n            assert any(item.get(\"type\") == \"string\" for item in model_name_schema[\"anyOf\"]), \"model_name should allow string type\"\n            assert any(item.get(\"type\") == \"null\" for item in model_name_schema[\"anyOf\"]), \"model_name should allow null type\"\n            \n            # New parameters for geometry\n            assert (\n                detect_tool.inputSchema[\"properties\"][\"return_geometry\"].get(\"type\")\n                == \"boolean\"\n            ), \"return_geometry should be of type boolean\"\n            assert (\n                detect_tool.inputSchema[\"properties\"][\"return_geometry\"].get(\"default\")\n                is False\n            ), \"return_geometry default should be False\"\n\n            assert (\n                detect_tool.inputSchema[\"properties\"][\"geometry_format\"].get(\"type\")\n                == \"string\"\n            ), \"geometry_format should be of type string\"\n            assert (\n                detect_tool.inputSchema[\"properties\"][\"geometry_format\"].get(\"enum\")\n                == [\"mask\", \"polygon\"]\n            ), \"geometry_format enum should be ['mask', 'polygon']\"\n            assert (\n                detect_tool.inputSchema[\"properties\"][\"geometry_format\"].get(\"default\")\n                == \"mask\"\n            ), \"geometry_format default should be 'mask'\"\n\n\nclass TestDetectToolExecution:\n    \"\"\"Tests for the detect tool execution and results.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.skipif(\n        os.environ.get(\"SKIP_YOLO_TESTS\") == \"1\",\n        reason=\"Skipping YOLO tests to avoid downloading models in CI\",\n    )\n    async def test_detect_tool_execution(self, mcp_server: FastMCP, test_image_path):\n        \"\"\"Tests the detect tool execution and return value.\"\"\"\n        # Skip if test image doesn't exist\n        if not os.path.exists(test_image_path):\n            pytest.skip(f\"Test image not found at {test_image_path}\")\n\n        async with Client(mcp_server) as client:\n            # Use the smallest model for faster tests\n            result = await client.call_tool(\n                \"detect\",\n                {\n                    \"input_path\": test_image_path,\n                },\n            )\n\n            # Parse the result\n            detection_result = result.structured_content\n            \n            # Check that the tool returned a result\n            assert detection_result is not None\n\n            # Basic structure checks\n            assert \"image_path\" in detection_result\n            assert \"detections\" in detection_result\n            assert detection_result[\"image_path\"] == test_image_path\n            assert isinstance(detection_result[\"detections\"], list)\n\n            # Check that we have at least some detections\n            assert len(detection_result[\"detections\"]) > 0, (\n                \"No objects detected in the test image\"\n            )\n\n            # Check the structure of a detection\n            detection = detection_result[\"detections\"][0]\n            assert \"class\" in detection, \"Detection should have a class name\"\n            assert \"confidence\" in detection, \"Detection should have a confidence score\"\n            assert \"bbox\" in detection, \"Detection should have a bounding box\"\n\n            # Check that the confidence is within expected range\n            assert 0 <= detection[\"confidence\"] <= 1, (\n                \"Confidence should be between 0 and 1\"\n            )\n\n            # Check that the bounding box has 4 coordinates\n            assert len(detection[\"bbox\"]) == 4, \"Bounding box should have 4 coordinates\"\n\n            # Check for expected classes in the image\n            # We expect at least one of these classes to be detected\n            expected_classes = [\"person\", \"car\", \"dog\"]\n            detected_classes = [d[\"class\"] for d in detection_result[\"detections\"]]\n\n            assert any(cls in detected_classes for cls in expected_classes), (\n                f\"None of the expected classes {expected_classes} were detected. \"\n                f\"Detected classes: {detected_classes}\"\n            )\n\n    @pytest.mark.asyncio\n    @pytest.mark.skipif(\n        os.environ.get(\"SKIP_YOLO_TESTS\") == \"1\",\n        reason=\"Skipping YOLO tests to avoid downloading models in CI\",\n    )\n    async def test_detect_with_mask_geometry(self, mcp_server: FastMCP, test_image_path):\n        \"\"\"Tests the detect tool with mask geometry return.\"\"\"\n        if not os.path.exists(test_image_path):\n            pytest.skip(f\"Test image not found at {test_image_path}\")\n\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"detect\",\n                {\n                    \"input_path\": test_image_path,\n                    \"model_name\": \"yoloe-11s-seg-pf.pt\",\n                    \"return_geometry\": True,\n                    \"geometry_format\": \"mask\",\n                    \"confidence\": 0.3,\n                },\n            )\n            detection_result = result.structured_content\n            assert len(detection_result[\"detections\"]) > 0\n\n            for detection in detection_result[\"detections\"]:\n                assert \"mask_path\" in detection\n                assert \"polygon\" not in detection\n                mask_path = detection[\"mask_path\"]\n                assert isinstance(mask_path, str)\n                assert os.path.exists(mask_path)\n\n    @pytest.mark.asyncio\n    @pytest.mark.skipif(\n        os.environ.get(\"SKIP_YOLO_TESTS\") == \"1\",\n        reason=\"Skipping YOLO tests to avoid downloading models in CI\",\n    )\n    async def test_detect_with_polygon_geometry(self, mcp_server: FastMCP, test_image_path):\n        \"\"\"Tests the detect tool with polygon geometry return.\"\"\"\n        if not os.path.exists(test_image_path):\n            pytest.skip(f\"Test image not found at {test_image_path}\")\n\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"detect\",\n                {\n                    \"input_path\": test_image_path,\n                    \"model_name\": \"yoloe-11s-seg-pf.pt\",\n                    \"return_geometry\": True,\n                    \"geometry_format\": \"polygon\",\n                    \"confidence\": 0.3,\n                },\n            )\n            detection_result = result.structured_content\n            assert detection_result is not None\n            assert len(detection_result[\"detections\"]) > 0\n            detection = detection_result[\"detections\"][0]\n            assert \"polygon\" in detection\n            assert \"mask\" not in detection\n            polygon_data = detection[\"polygon\"]\n            assert isinstance(polygon_data, list)\n            assert len(polygon_data) > 0\n            # It's a list of points [x, y]\n            assert isinstance(polygon_data[0], list)\n            assert len(polygon_data[0]) == 2\n\n    @pytest.mark.asyncio\n    @pytest.mark.skipif(\n        os.environ.get(\"SKIP_YOLO_TESTS\") == \"1\",\n        reason=\"Skipping YOLO tests to avoid downloading models in CI\",\n    )\n    async def test_detect_no_geometry_by_default(self, mcp_server: FastMCP, test_image_path):\n        \"\"\"Tests that no geometry is returned by default.\"\"\"\n        if not os.path.exists(test_image_path):\n            pytest.skip(f\"Test image not found at {test_image_path}\")\n\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"detect\",\n                {\n                    \"input_path\": test_image_path,\n                    \"model_name\": \"yoloe-11s-seg-pf.pt\",\n                    \"confidence\": 0.3,\n                },\n            )\n            detection_result = result.structured_content\n            assert detection_result is not None\n            assert len(detection_result[\"detections\"]) > 0\n            detection = detection_result[\"detections\"][0]\n            assert \"mask_path\" not in detection\n            assert \"polygon\" not in detection\n\n    @pytest.mark.asyncio\n    @pytest.mark.skipif(\n        os.environ.get(\"SKIP_YOLO_TESTS\") == \"1\",\n        reason=\"Skipping YOLO tests to avoid downloading models in CI\",\n    )\n    async def test_detect_geometry_with_non_seg_model_raises_error(\n        self, mcp_server: FastMCP, test_image_path, caplog\n    ):\n        \"\"\"Tests that requesting geometry with a non-segmentation model raises an error.\"\"\"\n        if not os.path.exists(test_image_path):\n            pytest.skip(f\"Test image not found at {test_image_path}\")\n\n        non_seg_model = \"yolov8n.pt\"\n        model_path = os.path.join(\"models\", non_seg_model)\n        if not os.path.exists(model_path):\n            pytest.skip(f\"Non-segmentation model '{non_seg_model}' not found for testing.\")\n\n        async with Client(mcp_server) as client:\n            from fastmcp.exceptions import ToolError\n            \n            with pytest.raises(ToolError):\n                await client.call_tool(\n                    \"detect\",\n                    {\n                        \"input_path\": test_image_path,\n                        \"model_name\": non_seg_model,\n                        \"return_geometry\": True,\n                    },\n                )\n            \n            assert any(\"does not support segmentation\" in record.message for record in caplog.records), \\\n                \"Expected error about segmentation not supported to be logged\"\n    \n    @pytest.mark.asyncio\n    @pytest.mark.skipif(\n        os.environ.get(\"SKIP_YOLO_TESTS\") == \"1\",\n        reason=\"Skipping YOLO tests to avoid downloading models in CI\",\n    )\n    async def test_detect_negative_scenario(\n        self, mcp_server: FastMCP, test_image_negative_path\n    ):\n        \"\"\"Tests that certain objects are not detected in an image where they don't\n        exist.\n        \"\"\"\n        # Skip if test image doesn't exist\n        if not os.path.exists(test_image_negative_path):\n            pytest.skip(f\"Test image not found at {test_image_negative_path}\")\n\n        async with Client(mcp_server) as client:\n            # Use the smallest model for faster tests\n            result = await client.call_tool(\n                \"detect\",\n                {\n                    \"input_path\": test_image_negative_path,\n                    \"confidence\": 0.5,\n                    \"model_name\": \"yoloe-11s-seg-pf.pt\",\n                },\n            )\n\n            # Parse the result\n            detection_result = result.structured_content\n            \n            # Check that the tool returned a result\n            assert detection_result is not None\n\n            # Basic structure checks\n            assert \"image_path\" in detection_result\n            assert \"detections\" in detection_result\n            assert detection_result[\"image_path\"] == test_image_negative_path\n            assert isinstance(detection_result[\"detections\"], list)\n\n            # Check that we have at least some detections\n            assert len(detection_result[\"detections\"]) > 0, (\n                \"No objects detected in the test image\"\n            )\n\n            # Objects that should NOT be detected in this image\n            not_expected_classes = [\"person\", \"car\", \"dog\", \"truck\", \"bus\"]\n            detected_classes = [d[\"class\"] for d in detection_result[\"detections\"]]\n\n            # Check that none of the not expected classes are detected\n            for cls in not_expected_classes:\n                assert cls not in detected_classes, (\n                    f\"Class '{cls}' was detected but should not be present in the image\"\n                )\n\n            # Objects that SHOULD be detected in this image\n            expected_classes = [\"bicycle\", \"cat\"]\n\n            # Check that at least one of the expected classes is detected\n            assert any(cls in detected_classes for cls in expected_classes), (\n                f\"None of the expected classes {expected_classes} were detected. \"\n                f\"Detected classes: {detected_classes}\"\n            )\n\n\nclass TestDetectGeometryValidation:\n    \"\"\"Tests for validating the correctness of masks and polygons returned by detect tool.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.skipif(\n        os.environ.get(\"SKIP_YOLO_TESTS\") == \"1\",\n        reason=\"Skipping YOLO tests to avoid downloading models in CI\",\n    )\n    async def test_mask_correctness(self, mcp_server: FastMCP, test_image_path):\n        \"\"\"Tests that returned masks are valid and correctly positioned.\"\"\"\n        # Load the test image to get its dimensions\n        with Image.open(test_image_path) as img:\n            orig_width, orig_height = img.size\n\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"detect\",\n                {\n                    \"input_path\": test_image_path,\n                    \"model_name\": \"yoloe-11s-seg-pf.pt\",\n                    \"return_geometry\": True,\n                    \"geometry_format\": \"mask\",\n                    \"confidence\": 0.3,\n                },\n            )\n            \n            detection_result = result.structured_content\n            assert detection_result is not None\n            assert len(detection_result[\"detections\"]) > 0\n            \n            for detection in detection_result[\"detections\"]:\n                assert \"mask_path\" in detection\n                mask_path = detection[\"mask_path\"]\n                assert os.path.exists(mask_path)\n                \n                mask = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE)\n                assert mask is not None\n                \n                bbox = detection[\"bbox\"]\n                x1, y1, x2, y2 = bbox\n\n                mask_height, mask_width = mask.shape\n\n                assert (\n                    (mask_height == mask_width) or\n                    (mask_height == orig_height and mask_width == orig_width)\n                ), f\"Mask dimensions {mask.shape} should be square or match original image\"\n\n                scale_x = orig_width / mask_width\n                scale_y = orig_height / mask_height\n\n                unique_values = np.unique(mask)\n                assert len(unique_values) <= 2, \"Mask should be binary\"\n                assert all(v in [0, 255] for v in unique_values), (\n                    \"Mask should contain only 0/255 values\"\n                )\n\n                assert np.sum(mask) > 0, \"Mask should not be empty\"\n\n                mask_indices = np.where(mask > 0)\n                if len(mask_indices[0]) > 0:\n                    min_y, max_y = mask_indices[0].min(), mask_indices[0].max()\n                    min_x, max_x = mask_indices[1].min(), mask_indices[1].max()\n\n                    scaled_x1 = x1 / scale_x\n                    scaled_x2 = x2 / scale_x\n                    scaled_y1 = y1 / scale_y\n                    scaled_y2 = y2 / scale_y\n\n                    tolerance = 10\n                    assert min_x >= scaled_x1 - tolerance\n                    assert max_x <= scaled_x2 + tolerance\n                    assert min_y >= scaled_y1 - tolerance\n                    assert max_y <= scaled_y2 + tolerance\n\n                mask_area = np.sum(mask > 0)\n                scaled_bbox_area = ((scaled_x2 - scaled_x1) * (scaled_y2 - scaled_y1))\n                coverage_ratio = mask_area / scaled_bbox_area if scaled_bbox_area > 0 else 0\n\n                assert 0.1 <= coverage_ratio <= 1.5\n\n    @pytest.mark.asyncio\n    @pytest.mark.skipif(\n        os.environ.get(\"SKIP_YOLO_TESTS\") == \"1\",\n        reason=\"Skipping YOLO tests to avoid downloading models in CI\",\n    )\n    async def test_polygon_correctness(self, mcp_server: FastMCP, test_image_path):\n        \"\"\"Tests that returned polygons are valid and correctly positioned.\"\"\"\n        # Load the test image to get its dimensions\n        with Image.open(test_image_path) as img:\n            img_width, img_height = img.size\n\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"detect\",\n                {\n                    \"input_path\": test_image_path,\n                    \"model_name\": \"yoloe-11s-seg-pf.pt\",\n                    \"return_geometry\": True,\n                    \"geometry_format\": \"polygon\",\n                    \"confidence\": 0.3,\n                },\n            )\n            \n            detection_result = result.structured_content\n            assert detection_result is not None\n            assert len(detection_result[\"detections\"]) > 0\n            \n            for detection in detection_result[\"detections\"]:\n                polygon = detection[\"polygon\"]\n                bbox = detection[\"bbox\"]\n                x1, y1, x2, y2 = bbox\n                \n                # 1. Check polygon has at least 3 points\n                assert len(polygon) >= 3, \"Polygon should have at least 3 points\"\n                \n                # 2. Check all points have exactly 2 coordinates\n                for point in polygon:\n                    assert len(point) == 2, f\"Each polygon point should have 2 coordinates, got {len(point)}\"\n                \n                # 3. Check all coordinates are reasonable\n                # Note: Polygon coordinates should be in original image space\n                for x, y in polygon:\n                    # Allow some tolerance outside image bounds\n                    tolerance = 10\n                    assert -tolerance <= x <= img_width + tolerance, (\n                        f\"X coordinate {x} should be within image width {img_width} (with tolerance)\"\n                    )\n                    assert -tolerance <= y <= img_height + tolerance, (\n                        f\"Y coordinate {y} should be within image height {img_height} (with tolerance)\"\n                    )\n                \n                # 4. Check polygon points are within bbox bounds (with tolerance)\n                tolerance = 10\n                xs = [p[0] for p in polygon]\n                ys = [p[1] for p in polygon]\n                \n                assert min(xs) >= x1 - tolerance, f\"Min polygon x {min(xs)} should be >= bbox x1 {x1}\"\n                assert max(xs) <= x2 + tolerance, f\"Max polygon x {max(xs)} should be <= bbox x2 {x2}\"\n                assert min(ys) >= y1 - tolerance, f\"Min polygon y {min(ys)} should be >= bbox y1 {y1}\"\n                assert max(ys) <= y2 + tolerance, f\"Max polygon y {max(ys)} should be <= bbox y2 {y2}\"\n                \n                # 5. Check polygon area is positive (using shoelace formula)\n                area = 0\n                n = len(polygon)\n                for i in range(n):\n                    j = (i + 1) % n\n                    area += polygon[i][0] * polygon[j][1]\n                    area -= polygon[j][0] * polygon[i][1]\n                area = abs(area) / 2.0\n                \n                assert area > 0, \"Polygon area should be positive\"\n                \n                # 6. Check polygon area relative to bbox area\n                bbox_area = (x2 - x1) * (y2 - y1)\n                area_ratio = area / bbox_area\n                \n                assert 0.1 <= area_ratio <= 1.5, (\n                    f\"Polygon area ratio {area_ratio:.2f} should be reasonable relative to bbox\"\n                )\n\n    @pytest.mark.asyncio\n    @pytest.mark.skipif(\n        os.environ.get(\"SKIP_YOLO_TESTS\") == \"1\",\n        reason=\"Skipping YOLO tests to avoid downloading models in CI\",\n    )\n    async def test_mask_to_polygon_consistency(self, mcp_server: FastMCP, test_image_path):\n        \"\"\"Tests that mask and polygon representations are consistent for the same object.\"\"\"\n        with Image.open(test_image_path) as img:\n            orig_width, orig_height = img.size\n            \n        async with Client(mcp_server) as client:\n            mask_result = await client.call_tool(\"detect\", {\"input_path\": test_image_path, \"model_name\": \"yoloe-11s-seg-pf.pt\", \"return_geometry\": True, \"geometry_format\": \"mask\", \"confidence\": 0.5})\n            polygon_result = await client.call_tool(\"detect\", {\"input_path\": test_image_path, \"model_name\": \"yoloe-11s-seg-pf.pt\", \"return_geometry\": True, \"geometry_format\": \"polygon\", \"confidence\": 0.5})\n            \n            mask_data = mask_result.structured_content\n            polygon_data = polygon_result.structured_content\n            \n            assert len(mask_data[\"detections\"]) == len(polygon_data[\"detections\"])\n            \n            if len(mask_data[\"detections\"]) > 0:\n                mask_detections = sorted(mask_data[\"detections\"], key=lambda x: (x[\"class\"], -x[\"confidence\"]))\n                polygon_detections = sorted(polygon_data[\"detections\"], key=lambda x: (x[\"class\"], -x[\"confidence\"]))\n\n                mask_det = mask_detections[0]\n                polygon_det = polygon_detections[0]\n\n                mask_path = mask_det[\"mask_path\"]\n                mask = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE)\n\n                assert mask_det[\"class\"] == polygon_det[\"class\"]\n\n                mask_bbox = mask_det[\"bbox\"]\n                polygon_bbox = polygon_det[\"bbox\"]\n\n                bbox_tolerance = 20\n                for i in range(4):\n                    assert abs(mask_bbox[i] - polygon_bbox[i]) < bbox_tolerance\n\n                polygon_points = polygon_det[\"polygon\"]\n                mask_height, mask_width = mask.shape\n\n                img = Image.new('L', (mask_width, mask_height), 0)\n\n                scale_x = mask_width / orig_width\n                scale_y = mask_height / orig_height\n\n                scaled_polygon = [(p[0] * scale_x, p[1] * scale_y) for p in polygon_points]\n\n                ImageDraw.Draw(img).polygon(scaled_polygon, outline=1, fill=1)\n                polygon_mask = np.array(img)\n\n                mask_bool = mask > 0\n                polygon_mask_bool = polygon_mask > 0\n                intersection = np.logical_and(mask_bool, polygon_mask_bool).sum()\n                union = np.logical_or(mask_bool, polygon_mask_bool).sum()\n                iou = intersection / union if union > 0 else 0\n\n                assert iou > 0.5\n\n    @pytest.mark.asyncio\n    @pytest.mark.skipif(\n        os.environ.get(\"SKIP_YOLO_TESTS\") == \"1\",\n        reason=\"Skipping YOLO tests to avoid downloading models in CI\",\n    )\n    async def test_detect_mask_validation_on_simple_image(\n        self, mcp_server: FastMCP, test_segmentation_image_path\n    ):\n        \"\"\"\n        Tests that generated masks are valid using a simple, predictable image.\n        It checks for binarity and bounding box confinement for every generated mask.\n        \"\"\"\n        with Image.open(test_segmentation_image_path) as img:\n            orig_width, orig_height = img.size\n\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"detect\",\n                {\n                    \"input_path\": test_segmentation_image_path,\n                    \"model_name\": \"yoloe-11s-seg-pf.pt\",\n                    \"return_geometry\": True,\n                    \"geometry_format\": \"mask\",\n                    \"confidence\": 0.3,\n                },\n            )\n\n            detection_result = result.structured_content\n            assert detection_result is not None\n            \n            # We expect at least a \"dog\" and a \"cat\" to be detected\n            detected_classes = [d[\"class\"] for d in detection_result[\"detections\"]]\n            assert \"dog\" in detected_classes\n            assert \"cat\" in detected_classes\n            assert len(detection_result[\"detections\"]) >= 2\n\n            # Validate every mask that was generated\n            for detection in detection_result[\"detections\"]:\n                assert \"mask_path\" in detection, \"Each detection should have a mask_path\"\n                mask_path = detection[\"mask_path\"]\n                assert os.path.exists(mask_path), f\"Mask file should exist at {mask_path}\"\n\n                mask = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE)\n                assert mask is not None, f\"Mask file {mask_path} could not be read\"\n\n                # 1. Check for binarity (only 0 and 255 values)\n                unique_values = np.unique(mask)\n                assert all(v in [0, 255] for v in unique_values), (\n                    f\"Mask {mask_path} is not binary. Found values: {unique_values}\"\n                )\n                assert np.sum(mask) > 0, f\"Mask {mask_path} should not be empty\"\n\n                # 2. Check for bounding box confinement\n                bbox = detection[\"bbox\"]\n                x1, y1, x2, y2 = bbox\n                mask_height, mask_width = mask.shape\n\n                # The model might return a mask that is the size of the original image\n                # or a cropped, resized version. We need to handle both cases by scaling.\n                scale_x = orig_width / mask_width\n                scale_y = orig_height / mask_height\n\n                # Find the bounding box of the mask's content\n                mask_indices = np.where(mask > 0)\n                if len(mask_indices[0]) > 0:\n                    min_mask_y, max_mask_y = mask_indices[0].min(), mask_indices[0].max()\n                    min_mask_x, max_mask_x = mask_indices[1].min(), mask_indices[1].max()\n\n                    # Scale the detection bbox to the mask's coordinate system\n                    scaled_x1 = x1 / scale_x\n                    scaled_y1 = y1 / scale_y\n                    scaled_x2 = x2 / scale_x\n                    scaled_y2 = y2 / scale_y\n\n                    # Check if the mask's content is within the scaled bbox (with tolerance)\n                    tolerance = 10  # Use a small tolerance\n                    assert min_mask_x >= scaled_x1 - tolerance, f\"Mask content of {mask_path} extends past the left of its bbox\"\n                    assert max_mask_x <= scaled_x2 + tolerance, f\"Mask content of {mask_path} extends past the right of its bbox\"\n                    assert min_mask_y >= scaled_y1 - tolerance, f\"Mask content of {mask_path} extends past the top of its bbox\"\n                    assert max_mask_y <= scaled_y2 + tolerance, f\"Mask content of {mask_path} extends past the bottom of its bbox\"\n"
  },
  {
    "path": "tests/tools/test_draw_arrows.py",
    "content": "import os\n\nimport cv2\nimport numpy as np\nimport pytest\nfrom fastmcp import Client, FastMCP\n\nfrom imagesorcery_mcp.server import mcp as image_sorcery_mcp_server\n\n\n@pytest.fixture\ndef mcp_server():\n    # Use the existing server instance\n    return image_sorcery_mcp_server\n\n\n@pytest.fixture\ndef test_image_path(tmp_path):\n    \"\"\"Create a test image for drawing arrows.\"\"\"\n    img_path = tmp_path / \"test_image.png\"\n    # Create a white image\n    img = np.ones((300, 400, 3), dtype=np.uint8) * 255\n    cv2.imwrite(str(img_path), img)\n    return str(img_path)\n\n\nclass TestDrawArrowsToolDefinition:\n    \"\"\"Tests for the draw_arrows tool definition and metadata.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_draw_arrows_in_tools_list(self, mcp_server: FastMCP):\n        \"\"\"Tests that draw_arrows tool is in the list of available tools.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            assert tools, \"Tools list should not be empty\"\n            tool_names = [tool.name for tool in tools]\n            assert \"draw_arrows\" in tool_names, \\\n                \"draw_arrows tool should be in the list of available tools\"\n\n    @pytest.mark.asyncio\n    async def test_draw_arrows_description(self, mcp_server: FastMCP):\n        \"\"\"Tests that draw_arrows tool has the correct description.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            draw_arrows_tool = next((tool for tool in tools if tool.name == \"draw_arrows\"), None)\n            assert draw_arrows_tool.description, \"draw_arrows tool should have a description\"\n            assert \"arrow\" in draw_arrows_tool.description.lower(), \\\n                \"Description should mention that it draws arrows on an image\"\n\n    @pytest.mark.asyncio\n    async def test_draw_arrows_parameters(self, mcp_server: FastMCP):\n        \"\"\"Tests that draw_arrows tool has the correct parameter structure.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            draw_arrows_tool = next((tool for tool in tools if tool.name == \"draw_arrows\"), None)\n\n            assert hasattr(draw_arrows_tool, \"inputSchema\"), \\\n                \"draw_arrows tool should have an inputSchema\"\n            assert \"properties\" in draw_arrows_tool.inputSchema, \\\n                \"inputSchema should have properties field\"\n\n            required_params = [\"input_path\", \"arrows\"]\n            for param in required_params:\n                assert param in draw_arrows_tool.inputSchema[\"properties\"], \\\n                    f\"draw_arrows tool should have a '{param}' property in its inputSchema\"\n\n            assert \"output_path\" in draw_arrows_tool.inputSchema[\"properties\"], \\\n                \"draw_arrows tool should have an 'output_path' property in its inputSchema\"\n\n            assert draw_arrows_tool.inputSchema[\"properties\"][\"input_path\"].get(\"type\") == \"string\", \\\n                \"input_path should be of type string\"\n            assert draw_arrows_tool.inputSchema[\"properties\"][\"arrows\"].get(\"type\") == \"array\", \\\n                \"arrows should be of type array\"\n            \n            arrows_items_schema = draw_arrows_tool.inputSchema[\"properties\"][\"arrows\"].get(\"items\", {})\n            assert arrows_items_schema.get(\"type\") == \"object\", \"arrows items should be objects\"\n\n            output_path_schema = draw_arrows_tool.inputSchema[\"properties\"][\"output_path\"]\n            assert \"anyOf\" in output_path_schema, \"output_path should have anyOf field for optional types\"\n            string_type_present = any(\n                type_option.get(\"type\") == \"string\" \n                for type_option in output_path_schema[\"anyOf\"]\n            )\n            assert string_type_present, \"output_path should allow string type\"\n\n\nclass TestDrawArrowsToolExecution:\n    \"\"\"Tests for the draw_arrows tool execution and results.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_draw_arrows_tool_execution(\n        self, mcp_server: FastMCP, test_image_path, tmp_path\n    ):\n        output_path = str(tmp_path / \"output_arrows.png\")\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"draw_arrows\",\n                {\n                    \"input_path\": test_image_path,\n                    \"arrows\": [\n                        {\"x1\": 50, \"y1\": 50, \"x2\": 150, \"y2\": 100, \"color\": [0, 0, 255], \"thickness\": 2, \"tip_length\": 0.2},\n                        {\"x1\": 200, \"y1\": 150, \"x2\": 300, \"y2\": 250, \"color\": [255, 0, 0], \"thickness\": 3, \"tip_length\": 0.15}\n                    ],\n                    \"output_path\": output_path,\n                },\n            )\n            assert result.data == output_path\n            assert os.path.exists(output_path)\n            img = cv2.imread(output_path)\n            assert img.shape[:2] == (300, 400)\n            \n            # Check if pixels along the arrow path are changed (not white)\n            # For the first arrow (red: BGR [0,0,255])\n            # Midpoint of the first arrow: ( (50+150)/2, (50+100)/2 ) = (100, 75)\n            # Check a pixel near the midpoint\n            assert not np.array_equal(img[75, 100], [255, 255, 255]), \"First arrow (red) should be drawn\"\n            \n            # For the second arrow (blue: BGR [255,0,0])\n            # Midpoint of the second arrow: ( (200+300)/2, (150+250)/2 ) = (250, 200)\n            assert not np.array_equal(img[200, 250], [255, 255, 255]), \"Second arrow (blue) should be drawn\"\n\n    @pytest.mark.asyncio\n    async def test_draw_arrows_default_parameters(\n        self, mcp_server: FastMCP, test_image_path, tmp_path\n    ):\n        output_path = str(tmp_path / \"default_arrows_output.png\")\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"draw_arrows\",\n                {\n                    \"input_path\": test_image_path,\n                    \"arrows\": [{\"x1\": 10, \"y1\": 10, \"x2\": 100, \"y2\": 100}], # Use default color, thickness, tip_length\n                    \"output_path\": output_path,\n                },\n            )\n            assert result.data == output_path\n            assert os.path.exists(output_path)\n            img = cv2.imread(output_path)\n            # Check a pixel near the midpoint (55, 55) for default black color [0,0,0]\n            # It should not be white [255,255,255]\n            assert not np.array_equal(img[55, 55], [255, 255, 255]), \"Arrow with default parameters should be drawn\"\n\n    @pytest.mark.asyncio\n    async def test_draw_arrows_default_output_path(self, mcp_server: FastMCP, test_image_path):\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"draw_arrows\",\n                {\"input_path\": test_image_path, \"arrows\": [{\"x1\": 20, \"y1\": 20, \"x2\": 120, \"y2\": 120}]},\n            )\n            expected_output = test_image_path.replace(\".png\", \"_with_arrows.png\")\n            assert result.data == expected_output\n            assert os.path.exists(expected_output)\n            img = cv2.imread(expected_output)\n            assert img.shape[:2] == (300, 400)\n"
  },
  {
    "path": "tests/tools/test_draw_circle.py",
    "content": "import os\n\nimport cv2\nimport numpy as np\nimport pytest\nfrom fastmcp import Client, FastMCP\n\nfrom imagesorcery_mcp.server import mcp as image_sorcery_mcp_server\n\n\n@pytest.fixture\ndef mcp_server():\n    # Use the existing server instance\n    return image_sorcery_mcp_server\n\n\n@pytest.fixture\ndef test_image_path(tmp_path):\n    \"\"\"Create a test image for drawing circles.\"\"\"\n    img_path = tmp_path / \"test_image.png\"\n    # Create a white image\n    img = np.ones((300, 400, 3), dtype=np.uint8) * 255\n    cv2.imwrite(str(img_path), img)\n    return str(img_path)\n\n\nclass TestDrawCircleToolDefinition:\n    \"\"\"Tests for the draw_circles tool definition and metadata.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_draw_circles_in_tools_list(self, mcp_server: FastMCP):\n        \"\"\"Tests that draw_circles tool is in the list of available tools.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            assert tools, \"Tools list should not be empty\"\n            tool_names = [tool.name for tool in tools]\n            assert \"draw_circles\" in tool_names, \\\n                \"draw_circles tool should be in the list of available tools\"\n\n    @pytest.mark.asyncio\n    async def test_draw_circles_description(self, mcp_server: FastMCP):\n        \"\"\"Tests that draw_circles tool has the correct description.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            draw_circles_tool = next((tool for tool in tools if tool.name == \"draw_circles\"), None)\n            assert draw_circles_tool.description, \"draw_circles tool should have a description\"\n            assert \"circle\" in draw_circles_tool.description.lower(), \\\n                \"Description should mention that it draws circles on an image\"\n\n    @pytest.mark.asyncio\n    async def test_draw_circles_parameters(self, mcp_server: FastMCP):\n        \"\"\"Tests that draw_circles tool has the correct parameter structure.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            draw_circles_tool = next((tool for tool in tools if tool.name == \"draw_circles\"), None)\n\n            assert hasattr(draw_circles_tool, \"inputSchema\"), \\\n                \"draw_circles tool should have an inputSchema\"\n            assert \"properties\" in draw_circles_tool.inputSchema, \\\n                \"inputSchema should have properties field\"\n\n            required_params = [\"input_path\", \"circles\"]\n            for param in required_params:\n                assert param in draw_circles_tool.inputSchema[\"properties\"], \\\n                    f\"draw_circles tool should have a '{param}' property in its inputSchema\"\n\n            assert \"output_path\" in draw_circles_tool.inputSchema[\"properties\"], \\\n                \"draw_circles tool should have an 'output_path' property in its inputSchema\"\n\n            assert draw_circles_tool.inputSchema[\"properties\"][\"input_path\"].get(\"type\") == \"string\", \\\n                \"input_path should be of type string\"\n            assert draw_circles_tool.inputSchema[\"properties\"][\"circles\"].get(\"type\") == \"array\", \\\n                \"circles should be of type array\"\n\n            circles_items_schema = draw_circles_tool.inputSchema[\"properties\"][\"circles\"].get(\"items\", {})\n\n            if \"$ref\" in circles_items_schema:\n                ref_path = circles_items_schema[\"$ref\"]\n                model_name = ref_path.split(\"/\")[-1]\n                defs_schema = draw_circles_tool.inputSchema.get(\"$defs\", {})\n                assert model_name in defs_schema, f\"'$defs' should contain a definition for '{model_name}'\"\n                circle_item_schema = defs_schema[model_name]\n            else:\n                circle_item_schema = circles_items_schema\n\n            assert circle_item_schema.get(\"type\") == \"object\", \"Circle item schema should be an object\"\n\n            circles_props = circle_item_schema.get(\"properties\", {})\n            assert \"center_x\" in circles_props and circles_props[\"center_x\"].get(\"type\") == \"integer\"\n            assert \"center_y\" in circles_props and circles_props[\"center_y\"].get(\"type\") == \"integer\"\n            assert \"radius\" in circles_props and circles_props[\"radius\"].get(\"type\") == \"integer\"\n            \n            required_circle_item_fields = circle_item_schema.get(\"required\", [])\n            assert \"center_x\" in required_circle_item_fields\n            assert \"center_y\" in required_circle_item_fields\n            assert \"radius\" in required_circle_item_fields\n\n            assert \"color\" in circles_props, \"'color' property should be in circles_props\"\n            assert \"thickness\" in circles_props, \"'thickness' property should be in circles_props\"\n            assert \"filled\" in circles_props, \"'filled' property should be in circles_props\"\n\n            output_path_schema = draw_circles_tool.inputSchema[\"properties\"][\"output_path\"]\n            assert \"anyOf\" in output_path_schema, \"output_path should have anyOf field for optional types\"\n            string_type_present = any(\n                type_option.get(\"type\") == \"string\" \n                for type_option in output_path_schema[\"anyOf\"]\n            )\n            assert string_type_present, \"output_path should allow string type\"\n\n\nclass TestDrawCircleToolExecution:\n    \"\"\"Tests for the draw_circles tool execution and results.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_draw_circles_tool_execution(\n        self, mcp_server: FastMCP, test_image_path, tmp_path\n    ):\n        output_path = str(tmp_path / \"output_circles.png\")\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"draw_circles\",\n                {\n                    \"input_path\": test_image_path,\n                    \"circles\": [\n                        {\"center_x\": 100, \"center_y\": 100, \"radius\": 50, \"color\": [0, 0, 255], \"thickness\": 2},\n                        {\"center_x\": 250, \"center_y\": 150, \"radius\": 30, \"color\": [255, 0, 0], \"thickness\": 3}\n                    ],\n                    \"output_path\": output_path,\n                },\n            )\n            assert result.data == output_path\n            assert os.path.exists(output_path)\n            img = cv2.imread(output_path)\n            assert img.shape[:2] == (300, 400)\n            assert not np.array_equal(img[100, 150-1], [255, 255, 255]), \"Circle 1 (red) should be drawn\"\n            assert not np.array_equal(img[150, 280-1], [255, 255, 255]), \"Circle 2 (blue) should be drawn\"\n\n    @pytest.mark.asyncio\n    async def test_draw_filled_circle(\n        self, mcp_server: FastMCP, test_image_path, tmp_path\n    ):\n        output_path = str(tmp_path / \"filled_circle_output.png\")\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"draw_circles\",\n                {\n                    \"input_path\": test_image_path,\n                    \"circles\": [{\"center_x\": 150, \"center_y\": 150, \"radius\": 50, \"color\": [0, 255, 0], \"filled\": True}],\n                    \"output_path\": output_path,\n                },\n            )\n            assert result.data == output_path\n            assert os.path.exists(output_path)\n            img = cv2.imread(output_path)\n            assert np.array_equal(img[150, 150], [0, 255, 0]), \"Circle should be filled with green\"\n            assert np.array_equal(img[150 + 40, 150 + 0], [0, 255, 0]), \"Inner part of filled circle should be green\"\n\n    @pytest.mark.asyncio\n    async def test_draw_circles_default_output_path(self, mcp_server: FastMCP, test_image_path):\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"draw_circles\",\n                {\"input_path\": test_image_path, \"circles\": [{\"center_x\": 50, \"center_y\": 50, \"radius\": 20}]},\n            )\n            expected_output = test_image_path.replace(\".png\", \"_with_circles.png\")\n            assert result.data == expected_output\n            assert os.path.exists(expected_output)\n            img = cv2.imread(expected_output)\n            assert img.shape[:2] == (300, 400)\n"
  },
  {
    "path": "tests/tools/test_draw_lines.py",
    "content": "import os\n\nimport cv2\nimport numpy as np\nimport pytest\nfrom fastmcp import Client, FastMCP\n\nfrom imagesorcery_mcp.server import mcp as image_sorcery_mcp_server\n\n\n@pytest.fixture\ndef mcp_server():\n    # Use the existing server instance\n    return image_sorcery_mcp_server\n\n\n@pytest.fixture\ndef test_image_path(tmp_path):\n    \"\"\"Create a test image for drawing lines.\"\"\"\n    img_path = tmp_path / \"test_image.png\"\n    # Create a white image\n    img = np.ones((300, 400, 3), dtype=np.uint8) * 255\n    cv2.imwrite(str(img_path), img)\n    return str(img_path)\n\n\nclass TestDrawLinesToolDefinition:\n    \"\"\"Tests for the draw_lines tool definition and metadata.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_draw_lines_in_tools_list(self, mcp_server: FastMCP):\n        \"\"\"Tests that draw_lines tool is in the list of available tools.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            assert tools, \"Tools list should not be empty\"\n            tool_names = [tool.name for tool in tools]\n            assert \"draw_lines\" in tool_names, \\\n                \"draw_lines tool should be in the list of available tools\"\n\n    @pytest.mark.asyncio\n    async def test_draw_lines_description(self, mcp_server: FastMCP):\n        \"\"\"Tests that draw_lines tool has the correct description.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            draw_lines_tool = next((tool for tool in tools if tool.name == \"draw_lines\"), None)\n            assert draw_lines_tool.description, \"draw_lines tool should have a description\"\n            assert \"line\" in draw_lines_tool.description.lower(), \\\n                \"Description should mention that it draws lines on an image\"\n\n    @pytest.mark.asyncio\n    async def test_draw_lines_parameters(self, mcp_server: FastMCP):\n        \"\"\"Tests that draw_lines tool has the correct parameter structure.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            draw_lines_tool = next((tool for tool in tools if tool.name == \"draw_lines\"), None)\n\n            assert hasattr(draw_lines_tool, \"inputSchema\"), \\\n                \"draw_lines tool should have an inputSchema\"\n            assert \"properties\" in draw_lines_tool.inputSchema, \\\n                \"inputSchema should have properties field\"\n\n            required_params = [\"input_path\", \"lines\"]\n            for param in required_params:\n                assert param in draw_lines_tool.inputSchema[\"properties\"], \\\n                    f\"draw_lines tool should have a '{param}' property in its inputSchema\"\n\n            assert \"output_path\" in draw_lines_tool.inputSchema[\"properties\"], \\\n                \"draw_lines tool should have an 'output_path' property in its inputSchema\"\n\n            assert draw_lines_tool.inputSchema[\"properties\"][\"input_path\"].get(\"type\") == \"string\", \\\n                \"input_path should be of type string\"\n            assert draw_lines_tool.inputSchema[\"properties\"][\"lines\"].get(\"type\") == \"array\", \\\n                \"lines should be of type array\"\n            \n            lines_items_schema = draw_lines_tool.inputSchema[\"properties\"][\"lines\"].get(\"items\", {})\n            assert lines_items_schema.get(\"type\") == \"object\", \"lines items should be objects\"\n\n            output_path_schema = draw_lines_tool.inputSchema[\"properties\"][\"output_path\"]\n            assert \"anyOf\" in output_path_schema, \"output_path should have anyOf field for optional types\"\n            string_type_present = any(\n                type_option.get(\"type\") == \"string\" \n                for type_option in output_path_schema[\"anyOf\"]\n            )\n            assert string_type_present, \"output_path should allow string type\"\n\n\nclass TestDrawLinesToolExecution:\n    \"\"\"Tests for the draw_lines tool execution and results.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_draw_lines_tool_execution(\n        self, mcp_server: FastMCP, test_image_path, tmp_path\n    ):\n        output_path = str(tmp_path / \"output_lines.png\")\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"draw_lines\",\n                {\n                    \"input_path\": test_image_path,\n                    \"lines\": [\n                        {\"x1\": 50, \"y1\": 50, \"x2\": 150, \"y2\": 100, \"color\": [0, 0, 255], \"thickness\": 2},\n                        {\"x1\": 200, \"y1\": 150, \"x2\": 300, \"y2\": 250, \"color\": [255, 0, 0], \"thickness\": 3}\n                    ],\n                    \"output_path\": output_path,\n                },\n            )\n            assert result.data == output_path\n            assert os.path.exists(output_path)\n            img = cv2.imread(output_path)\n            assert img.shape[:2] == (300, 400)\n            \n            # Check if pixels along the line path are changed (not white)\n            # For the first line (red: BGR [0,0,255])\n            # Midpoint of the first line: ( (50+150)/2, (50+100)/2 ) = (100, 75)\n            # Check a pixel near the midpoint\n            assert not np.array_equal(img[75, 100], [255, 255, 255]), \"First line (red) should be drawn\"\n            \n            # For the second line (blue: BGR [255,0,0])\n            # Midpoint of the second line: ( (200+300)/2, (150+250)/2 ) = (250, 200)\n            assert not np.array_equal(img[200, 250], [255, 255, 255]), \"Second line (blue) should be drawn\"\n\n    @pytest.mark.asyncio\n    async def test_draw_lines_default_parameters(\n        self, mcp_server: FastMCP, test_image_path, tmp_path\n    ):\n        output_path = str(tmp_path / \"default_lines_output.png\")\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"draw_lines\",\n                {\n                    \"input_path\": test_image_path,\n                    \"lines\": [{\"x1\": 10, \"y1\": 10, \"x2\": 100, \"y2\": 100}], # Use default color, thickness\n                    \"output_path\": output_path,\n                },\n            )\n            assert result.data == output_path\n            assert os.path.exists(output_path)\n            img = cv2.imread(output_path)\n            # Check a pixel near the midpoint (55, 55) for default black color [0,0,0]\n            # It should not be white [255,255,255]\n            assert not np.array_equal(img[55, 55], [255, 255, 255]), \"Line with default parameters should be drawn\"\n\n    @pytest.mark.asyncio\n    async def test_draw_lines_default_output_path(self, mcp_server: FastMCP, test_image_path):\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"draw_lines\",\n                {\"input_path\": test_image_path, \"lines\": [{\"x1\": 20, \"y1\": 20, \"x2\": 120, \"y2\": 120}]},\n            )\n            expected_output = test_image_path.replace(\".png\", \"_with_lines.png\")\n            assert result.data == expected_output\n            assert os.path.exists(expected_output)\n            img = cv2.imread(expected_output)\n            assert img.shape[:2] == (300, 400)\n"
  },
  {
    "path": "tests/tools/test_draw_rectangle.py",
    "content": "import os\n\nimport cv2\nimport numpy as np\nimport pytest\nfrom fastmcp import Client, FastMCP\n\nfrom imagesorcery_mcp.server import mcp as image_sorcery_mcp_server\n\n\n@pytest.fixture\ndef mcp_server():\n    # Use the existing server instance\n    return image_sorcery_mcp_server\n\n\n@pytest.fixture\ndef test_image_path(tmp_path):\n    \"\"\"Create a test image for drawing rectangles.\"\"\"\n    img_path = tmp_path / \"test_image.png\"\n    # Create a white image\n    img = np.ones((300, 400, 3), dtype=np.uint8) * 255\n    cv2.imwrite(str(img_path), img)\n    return str(img_path)\n\n\nclass TestDrawRectanglesToolDefinition:\n    \"\"\"Tests for the draw_rectangles tool definition and metadata.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_draw_rectangles_in_tools_list(self, mcp_server: FastMCP):\n        \"\"\"Tests that draw_rectangles tool is in the list of available tools.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            # Verify that tools list is not empty\n            assert tools, \"Tools list should not be empty\"\n\n            # Check if draw_rectangles is in the list of tools\n            tool_names = [tool.name for tool in tools]\n            assert \"draw_rectangles\" in tool_names, (\n                \"draw_rectangles tool should be in the list of available tools\"\n            )\n\n    @pytest.mark.asyncio\n    async def test_draw_rectangles_description(self, mcp_server: FastMCP):\n        \"\"\"Tests that draw_rectangles tool has the correct description.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            draw_rectangles_tool = next((tool for tool in tools if tool.name == \"draw_rectangles\"), None)\n\n            # Check description\n            assert draw_rectangles_tool.description, \"draw_rectangles tool should have a description\"\n            assert \"rectangle\" in draw_rectangles_tool.description.lower(), (\n                \"Description should mention that it draws rectangles on an image\"\n            )\n\n    @pytest.mark.asyncio\n    async def test_draw_rectangles_parameters(self, mcp_server: FastMCP):\n        \"\"\"Tests that draw_rectangles tool has the correct parameter structure.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            draw_rectangles_tool = next((tool for tool in tools if tool.name == \"draw_rectangles\"), None)\n\n            # Check input schema\n            assert hasattr(draw_rectangles_tool, \"inputSchema\"), (\n                \"draw_rectangles tool should have an inputSchema\"\n            )\n            assert \"properties\" in draw_rectangles_tool.inputSchema, (\n                \"inputSchema should have properties field\"\n            )\n\n            # Check required parameters\n            required_params = [\"input_path\", \"rectangles\"]\n            for param in required_params:\n                assert param in draw_rectangles_tool.inputSchema[\"properties\"], (\n                    f\"draw_rectangles tool should have a '{param}' property in its inputSchema\"\n                )\n\n            # Check optional parameters\n            assert \"output_path\" in draw_rectangles_tool.inputSchema[\"properties\"], (\n                \"draw_rectangles tool should have an 'output_path' property in its inputSchema\"\n            )\n\n            # Check parameter types\n            assert (\n                draw_rectangles_tool.inputSchema[\"properties\"][\"input_path\"].get(\"type\")\n                == \"string\"\n            ), \"input_path should be of type string\"\n            assert (\n                draw_rectangles_tool.inputSchema[\"properties\"][\"rectangles\"].get(\"type\")\n                == \"array\"\n            ), \"rectangles should be of type array\"\n            \n            # Check output_path type - it can be string or null since it's optional\n            output_path_schema = draw_rectangles_tool.inputSchema[\"properties\"][\"output_path\"]\n            assert \"anyOf\" in output_path_schema, \"output_path should have anyOf field for optional types\"\n            \n            # Check that string is one of the allowed types\n            string_type_present = any(\n                type_option.get(\"type\") == \"string\" \n                for type_option in output_path_schema[\"anyOf\"]\n            )\n            assert string_type_present, \"output_path should allow string type\"\n\n\nclass TestDrawRectanglesToolExecution:\n    \"\"\"Tests for the draw_rectangles tool execution and results.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_draw_rectangles_tool_execution(\n        self, mcp_server: FastMCP, test_image_path, tmp_path\n    ):\n        \"\"\"Tests the draw_rectangles tool execution and return value.\"\"\"\n        output_path = str(tmp_path / \"output.png\")\n        \n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"draw_rectangles\",\n                {\n                    \"input_path\": test_image_path,\n                    \"rectangles\": [\n                        {\n                            \"x1\": 50,\n                            \"y1\": 50,\n                            \"x2\": 150,\n                            \"y2\": 100,\n                            \"color\": [0, 0, 255],  # Red in BGR\n                            \"thickness\": 2\n                        },\n                        {\n                            \"x1\": 200,\n                            \"y1\": 150,\n                            \"x2\": 300,\n                            \"y2\": 250,\n                            \"color\": [255, 0, 0],  # Blue in BGR\n                            \"thickness\": 3\n                        }\n                    ],\n                    \"output_path\": output_path,\n                },\n            )\n\n            # Check that the tool returned a result\n            assert result.data == output_path\n\n            # Verify the file exists\n            assert os.path.exists(output_path)\n\n            # Verify the image was created with correct dimensions\n            img = cv2.imread(output_path)\n            assert img.shape[:2] == (300, 400)  # height, width\n            \n            # Verify that pixels at rectangle locations have changed color\n            # Check a point on the first rectangle's border\n            assert not np.array_equal(img[50, 50], [255, 255, 255]), \"Rectangle 1 should be drawn\"\n            \n            # Check a point on the second rectangle's border\n            assert not np.array_equal(img[150, 200], [255, 255, 255]), \"Rectangle 2 should be drawn\"\n\n    @pytest.mark.asyncio\n    async def test_draw_filled_rectangle(\n        self, mcp_server: FastMCP, test_image_path, tmp_path\n    ):\n        \"\"\"Tests drawing a filled rectangle.\"\"\"\n        output_path = str(tmp_path / \"filled_output.png\")\n        \n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"draw_rectangles\",\n                {\n                    \"input_path\": test_image_path,\n                    \"rectangles\": [\n                        {\n                            \"x1\": 100,\n                            \"y1\": 100,\n                            \"x2\": 200,\n                            \"y2\": 200,\n                            \"color\": [0, 255, 0],  # Green in BGR\n                            \"filled\": True\n                        }\n                    ],\n                    \"output_path\": output_path,\n                },\n            )\n\n            # Check that the tool returned a result\n            assert result.data == output_path\n\n            # Verify the file exists\n            assert os.path.exists(output_path)\n\n            # Verify the image was created with correct dimensions\n            img = cv2.imread(output_path)\n            \n            # Check a point inside the filled rectangle\n            # It should be green (BGR: 0, 255, 0)\n            assert np.array_equal(img[150, 150], [0, 255, 0]), \"Rectangle should be filled with green\"\n\n    @pytest.mark.asyncio\n    async def test_draw_rectangles_default_output_path(self, mcp_server: FastMCP, test_image_path):\n        \"\"\"Tests the draw_rectangles tool with default output path.\"\"\"\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"draw_rectangles\",\n                {\n                    \"input_path\": test_image_path,\n                    \"rectangles\": [\n                        {\n                            \"x1\": 50,\n                            \"y1\": 50,\n                            \"x2\": 150,\n                            \"y2\": 100,\n                            \"color\": [0, 0, 0],  # Black in BGR\n                            \"thickness\": 2\n                        }\n                    ]\n                },\n            )\n\n            # Check that the tool returned a result\n            expected_output = test_image_path.replace(\".png\", \"_with_rectangles.png\")\n            assert result.data == expected_output\n\n            # Verify the file exists\n            assert os.path.exists(expected_output)\n            \n            # Verify the image was created with correct dimensions\n            img = cv2.imread(expected_output)\n            assert img.shape[:2] == (300, 400)  # height, width\n"
  },
  {
    "path": "tests/tools/test_draw_text.py",
    "content": "import os\n\nimport cv2\nimport easyocr\nimport numpy as np\nimport pytest\nfrom fastmcp import Client, FastMCP\n\nfrom imagesorcery_mcp.server import mcp as image_sorcery_mcp_server\n\n# Add this line to filter out the PyTorch warnings\npytestmark = pytest.mark.filterwarnings(\"ignore:.*'pin_memory' argument is set as true but no accelerator is found.*:UserWarning\")\n\n# Initialize the OCR reader for testing\nreader = None\n\n\ndef get_ocr_reader():\n    \"\"\"Get or initialize the EasyOCR reader for testing.\"\"\"\n    global reader\n    if reader is None:\n        reader = easyocr.Reader(['en'])\n    return reader\n\n\n@pytest.fixture\ndef mcp_server():\n    # Use the existing server instance\n    return image_sorcery_mcp_server\n\n\n@pytest.fixture\ndef test_image_path(tmp_path):\n    \"\"\"Create a test image for drawing text.\"\"\"\n    img_path = tmp_path / \"test_image.png\"\n    # Create a white image\n    img = np.ones((300, 400, 3), dtype=np.uint8) * 255\n    cv2.imwrite(str(img_path), img)\n    return str(img_path)\n\n\nclass TestDrawTextsToolDefinition:\n    \"\"\"Tests for the draw_texts tool definition and metadata.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_draw_texts_in_tools_list(self, mcp_server: FastMCP):\n        \"\"\"Tests that draw_texts tool is in the list of available tools.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            # Verify that tools list is not empty\n            assert tools, \"Tools list should not be empty\"\n\n            # Check if draw_texts is in the list of tools\n            tool_names = [tool.name for tool in tools]\n            assert \"draw_texts\" in tool_names, (\n                \"draw_texts tool should be in the list of available tools\"\n            )\n\n    @pytest.mark.asyncio\n    async def test_draw_texts_description(self, mcp_server: FastMCP):\n        \"\"\"Tests that draw_texts tool has the correct description.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            draw_texts_tool = next((tool for tool in tools if tool.name == \"draw_texts\"), None)\n\n            # Check description\n            assert draw_texts_tool.description, \"draw_texts tool should have a description\"\n            assert \"text\" in draw_texts_tool.description.lower(), (\n                \"Description should mention that it draws text on an image\"\n            )\n\n    @pytest.mark.asyncio\n    async def test_draw_texts_parameters(self, mcp_server: FastMCP):\n        \"\"\"Tests that draw_texts tool has the correct parameter structure.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            draw_texts_tool = next((tool for tool in tools if tool.name == \"draw_texts\"), None)\n\n            # Check input schema\n            assert hasattr(draw_texts_tool, \"inputSchema\"), (\n                \"draw_texts tool should have an inputSchema\"\n            )\n            assert \"properties\" in draw_texts_tool.inputSchema, (\n                \"inputSchema should have properties field\"\n            )\n\n            # Check required parameters\n            required_params = [\"input_path\", \"texts\"]\n            for param in required_params:\n                assert param in draw_texts_tool.inputSchema[\"properties\"], (\n                    f\"draw_texts tool should have a '{param}' property in its inputSchema\"\n                )\n\n            # Check optional parameters\n            assert \"output_path\" in draw_texts_tool.inputSchema[\"properties\"], (\n                \"draw_texts tool should have an 'output_path' property in its inputSchema\"\n            )\n\n            # Check parameter types\n            assert (\n                draw_texts_tool.inputSchema[\"properties\"][\"input_path\"].get(\"type\")\n                == \"string\"\n            ), \"input_path should be of type string\"\n            assert (\n                draw_texts_tool.inputSchema[\"properties\"][\"texts\"].get(\"type\")\n                == \"array\"\n            ), \"texts should be of type array\"\n            \n            # Check output_path type - it can be string or null since it's optional\n            output_path_schema = draw_texts_tool.inputSchema[\"properties\"][\"output_path\"]\n            assert \"anyOf\" in output_path_schema, \"output_path should have anyOf field for optional types\"\n            \n            # Check that string is one of the allowed types\n            string_type_present = any(\n                type_option.get(\"type\") == \"string\" \n                for type_option in output_path_schema[\"anyOf\"]\n            )\n            assert string_type_present, \"output_path should allow string type\"\n\n\nclass TestDrawTextsToolExecution:\n    \"\"\"Tests for the draw_texts tool execution and results.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_draw_texts_tool_execution(\n        self, mcp_server: FastMCP, test_image_path, tmp_path\n    ):\n        \"\"\"Tests the draw_texts tool execution and return value.\"\"\"\n        output_path = str(tmp_path / \"output.png\")\n        \n        # Define the text to draw\n        text1 = \"Hello World\"\n        text2 = \"Testing\"\n\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"draw_texts\",\n                {\n                    \"input_path\": test_image_path,\n                    \"texts\": [\n                        {\n                            \"text\": text1,\n                            \"x\": 50,\n                            \"y\": 50,\n                            \"font_scale\": 1.0,\n                            \"color\": [0, 0, 255],  # Red in BGR\n                            \"thickness\": 2\n                        },\n                        {\n                            \"text\": text2,\n                            \"x\": 100,\n                            \"y\": 150,\n                            \"font_scale\": 2.0,\n                            \"color\": [255, 0, 0],  # Blue in BGR\n                            \"thickness\": 3,\n                            \"font_face\": \"FONT_HERSHEY_COMPLEX\"\n                        }\n                    ],\n                    \"output_path\": output_path,\n                },\n            )\n\n            # Check that the tool returned a result\n            assert result.data == output_path\n\n            # Verify the file exists\n            assert os.path.exists(output_path)\n\n            # Verify the image was created with correct dimensions\n            img = cv2.imread(output_path)\n            assert img.shape[:2] == (300, 400)  # height, width\n            \n            # Use OCR to verify the text was actually drawn\n            reader = get_ocr_reader()\n            ocr_results = reader.readtext(output_path)\n            \n            # Extract the detected text\n            detected_texts = [result[1] for result in ocr_results]\n            \n            # Check if our drawn texts are detected by OCR\n            # We use partial matching because OCR might not be 100% accurate\n            assert any(text1 in detected_text for detected_text in detected_texts), \\\n                f\"Expected text '{text1}' not found in OCR results: {detected_texts}\"\n            assert any(text2 in detected_text for detected_text in detected_texts), \\\n                f\"Expected text '{text2}' not found in OCR results: {detected_texts}\"\n\n    @pytest.mark.asyncio\n    async def test_draw_texts_default_output_path(self, mcp_server: FastMCP, test_image_path):\n        \"\"\"Tests the draw_texts tool with default output path.\"\"\"\n        # Define the text to draw\n        test_text = \"Simple Text\"\n        \n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"draw_texts\",\n                {\n                    \"input_path\": test_image_path,\n                    \"texts\": [\n                        {\n                            \"text\": test_text,\n                            \"x\": 50,\n                            \"y\": 50,\n                            \"font_scale\": 1.5,  # Larger scale for better OCR detection\n                            \"thickness\": 2\n                        }\n                    ]\n                },\n            )\n\n            # Check that the tool returned a result\n            expected_output = test_image_path.replace(\".png\", \"_with_text.png\")\n            assert result.data == expected_output\n\n            # Verify the file exists\n            assert os.path.exists(expected_output)\n            \n            # Use OCR to verify the text was actually drawn\n            reader = get_ocr_reader()\n            ocr_results = reader.readtext(expected_output)\n            \n            # Extract the detected text\n            detected_texts = [result[1] for result in ocr_results]\n            \n            # Check if our drawn text is detected by OCR\n            assert any(test_text in detected_text for detected_text in detected_texts), \\\n                f\"Expected text '{test_text}' not found in OCR results: {detected_texts}\"\n\n    @pytest.mark.asyncio\n    async def test_draw_texts_minimal_parameters(self, mcp_server: FastMCP, test_image_path, tmp_path):\n        \"\"\"Tests the draw_texts tool with minimal required parameters.\"\"\"\n        output_path = str(tmp_path / \"minimal_output.png\")\n        \n        # Define the text to draw\n        test_text = \"Minimal Text\"\n        \n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"draw_texts\",\n                {\n                    \"input_path\": test_image_path,\n                    \"texts\": [\n                        {\n                            \"text\": test_text,\n                            \"x\": 50,\n                            \"y\": 50,\n                            \"font_scale\": 1.5,  # Larger scale for better OCR detection\n                            \"thickness\": 2\n                        }\n                    ],\n                    \"output_path\": output_path\n                },\n            )\n\n            # Check that the tool returned a result\n            assert result.data == output_path\n\n            # Verify the file exists\n            assert os.path.exists(output_path)\n            \n            # Use OCR to verify the text was actually drawn\n            reader = get_ocr_reader()\n            ocr_results = reader.readtext(output_path)\n            \n            # Extract the detected text\n            detected_texts = [result[1] for result in ocr_results]\n            \n            # Check if our drawn text is detected by OCR\n            assert any(test_text in detected_text for detected_text in detected_texts), \\\n                f\"Expected text '{test_text}' not found in OCR results: {detected_texts}\"\n            assert os.path.exists(output_path)\n"
  },
  {
    "path": "tests/tools/test_fill.py",
    "content": "import os\n\nimport cv2\nimport numpy as np\nimport pytest\nfrom fastmcp import Client, FastMCP\n\nfrom imagesorcery_mcp.server import mcp as image_sorcery_mcp_server\n\n\n@pytest.fixture\ndef mcp_server():\n    # Use the existing server instance\n    return image_sorcery_mcp_server\n\n\n@pytest.fixture\ndef test_image_path(tmp_path):\n    \"\"\"Create a test image with a black and white background for filling.\"\"\"\n    img_path = tmp_path / \"test_image.png\"\n    \n    # Create a white image\n    img = np.ones((300, 400, 3), dtype=np.uint8) * 255\n    \n    # Draw a black rectangle to check blending against\n    cv2.rectangle(img, (100, 75), (300, 225), (0, 0, 0), -1)\n    \n    cv2.imwrite(str(img_path), img)\n    return str(img_path)\n\n@pytest.fixture\ndef test_jpeg_image_path(tmp_path):\n    \"\"\"Create a test JPEG image (no alpha channel) for testing transparency operations.\"\"\"\n    img_path = tmp_path / \"test_image.jpg\"\n    \n    # Create a white image\n    img = np.ones((300, 400, 3), dtype=np.uint8) * 255\n    \n    # Draw a black rectangle to check blending against\n    cv2.rectangle(img, (100, 75), (300, 225), (0, 0, 0), -1)\n    \n    # Draw a red circle\n    cv2.circle(img, (200, 150), 50, (0, 0, 255), -1)\n    \n    cv2.imwrite(str(img_path), img)\n    return str(img_path)\n\n\nclass TestFillToolDefinition:\n    \"\"\"Tests for the fill tool definition and metadata.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_fill_in_tools_list(self, mcp_server: FastMCP):\n        \"\"\"Tests that fill tool is in the list of available tools.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            assert tools, \"Tools list should not be empty\"\n            tool_names = [tool.name for tool in tools]\n            assert \"fill\" in tool_names, \"fill tool should be in the list of available tools\"\n\n    @pytest.mark.asyncio\n    async def test_fill_description(self, mcp_server: FastMCP):\n        \"\"\"Tests that fill tool has the correct description.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            fill_tool = next((tool for tool in tools if tool.name == \"fill\"), None)\n            assert fill_tool.description, \"fill tool should have a description\"\n            assert \"fill\" in fill_tool.description.lower(), \"Description should mention that it fills areas of an image\"\n\n    @pytest.mark.asyncio\n    async def test_fill_parameters(self, mcp_server: FastMCP):\n        \"\"\"Tests that fill tool has the correct parameter structure.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            fill_tool = next((tool for tool in tools if tool.name == \"fill\"), None)\n            assert hasattr(fill_tool, \"inputSchema\"), \"fill tool should have an inputSchema\"\n            assert \"properties\" in fill_tool.inputSchema, \"inputSchema should have properties field\"\n            required_params = [\"input_path\", \"areas\"]\n            for param in required_params:\n                assert param in fill_tool.inputSchema[\"properties\"], f\"fill tool should have a '{param}' property in its inputSchema\"\n            assert \"output_path\" in fill_tool.inputSchema[\"properties\"], \"fill tool should have an 'output_path' property in its inputSchema\"\n            assert fill_tool.inputSchema[\"properties\"][\"input_path\"].get(\"type\") == \"string\", \"input_path should be of type string\"\n            assert fill_tool.inputSchema[\"properties\"][\"areas\"].get(\"type\") == \"array\", \"areas should be of type array\"\n            output_path_schema = fill_tool.inputSchema[\"properties\"][\"output_path\"]\n            assert \"anyOf\" in output_path_schema, \"output_path should have anyOf field for optional types\"\n            string_type_present = any(type_option.get(\"type\") == \"string\" for type_option in output_path_schema[\"anyOf\"])\n            assert string_type_present, \"output_path should allow string type\"\n\n\nclass TestFillToolExecution:\n    \"\"\"Tests for the fill tool execution and results.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_fill_tool_execution(self, mcp_server: FastMCP, test_image_path, tmp_path):\n        \"\"\"Tests the fill tool execution and return value.\"\"\"\n        output_path = str(tmp_path / \"output.png\")\n        \n        fill_area = {\"x1\": 150, \"y1\": 100, \"x2\": 250, \"y2\": 200, \"color\": [0, 0, 255], \"opacity\": 0.5}\n        \n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\"fill\", {\"input_path\": test_image_path, \"areas\": [fill_area], \"output_path\": output_path})\n            assert result.data == output_path\n            assert os.path.exists(output_path)\n\n            img = cv2.imread(output_path)\n            filled_pixel = img[150, 200]\n            assert np.allclose(filled_pixel, [0, 0, 128], atol=2)\n            unfilled_pixel = img[150, 120]\n            assert np.array_equal(unfilled_pixel, [0, 0, 0])\n            white_pixel = img[50, 50]\n            assert np.array_equal(white_pixel, [255, 255, 255])\n\n    @pytest.mark.asyncio\n    async def test_fill_polygon_area(self, mcp_server: FastMCP, test_image_path, tmp_path):\n        \"\"\"Tests the fill tool with a polygon area.\"\"\"\n        output_path = str(tmp_path / \"output_poly.png\")\n        polygon_area = {\"polygon\": [[160, 110], [240, 110], [200, 190]], \"color\": [0, 255, 0], \"opacity\": 0.8}\n\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\"fill\", {\"input_path\": test_image_path, \"areas\": [polygon_area], \"output_path\": output_path})\n            assert result.data == output_path\n            assert os.path.exists(output_path)\n\n            img = cv2.imread(output_path)\n            poly_center_pixel = img[130, 200]\n            assert np.allclose(poly_center_pixel, [0, 204, 0], atol=2)\n\n    @pytest.mark.asyncio\n    async def test_fill_default_output_path(self, mcp_server: FastMCP, test_image_path):\n        \"\"\"Tests the fill tool with default output path.\"\"\"\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\"fill\", {\"input_path\": test_image_path, \"areas\": [{\"x1\": 150, \"y1\": 100, \"x2\": 250, \"y2\": 200}]})\n            expected_output = test_image_path.replace(\".png\", \"_filled.png\")\n            assert result.data == expected_output\n            assert os.path.exists(expected_output)\n\n    @pytest.mark.asyncio\n    async def test_fill_multiple_areas(self, mcp_server: FastMCP, test_image_path, tmp_path):\n        \"\"\"Tests the fill tool with multiple overlapping areas.\"\"\"\n        output_path = str(tmp_path / \"multi_fill.png\")\n        \n        async with Client(mcp_server) as client:\n            await client.call_tool(\"fill\", {\"input_path\": test_image_path, \"areas\": [{\"x1\": 110, \"y1\": 85, \"x2\": 160, \"y2\": 135, \"color\": [0, 0, 255], \"opacity\": 1.0}, {\"x1\": 150, \"y1\": 125, \"x2\": 200, \"y2\": 175, \"color\": [0, 255, 0], \"opacity\": 0.5}], \"output_path\": output_path})\n            img = cv2.imread(output_path)\n            assert np.array_equal(img[100, 120], [0, 0, 255])\n            assert np.allclose(img[150, 160], [0, 128, 0], atol=2)\n            assert np.allclose(img[130, 155], [0, 128, 128], atol=2)\n\n    @pytest.mark.asyncio\n    async def test_fill_transparent_rectangle(self, mcp_server: FastMCP, test_image_path, tmp_path):\n        \"\"\"Tests making a rectangular area transparent with all channels set to 0.\"\"\"\n        output_path = str(tmp_path / \"output_transparent.png\")\n        fill_area = {\"x1\": 150, \"y1\": 100, \"x2\": 250, \"y2\": 200, \"color\": None}\n\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"fill\",\n                {\n                    \"input_path\": test_image_path,\n                    \"areas\": [fill_area],\n                    \"output_path\": output_path,\n                },\n            )\n            assert result.data == output_path\n            assert os.path.exists(output_path)\n\n            img = cv2.imread(output_path, cv2.IMREAD_UNCHANGED)\n            assert img.shape[2] == 4  # Should have alpha channel\n\n            # Check a pixel inside the transparent area - all channels should be 0\n            pixel_inside = img[150, 200]\n            assert np.array_equal(pixel_inside, [0, 0, 0, 0]), \"All BGRA channels should be 0 for transparent areas\"\n\n            # Check multiple pixels in the transparent area\n            for y in range(100, 200, 20):\n                for x in range(150, 250, 20):\n                    pixel = img[y, x]\n                    assert np.array_equal(pixel, [0, 0, 0, 0]), f\"Pixel at ({y}, {x}) should have all channels set to 0\"\n\n            # Check a pixel outside the transparent area\n            pixel_outside = img[50, 50]\n            assert pixel_outside[3] == 255  # Alpha should be 255\n            assert np.array_equal(pixel_outside[:3], [255, 255, 255])  # Should be white\n\n    @pytest.mark.asyncio\n    async def test_fill_transparent_polygon(self, mcp_server: FastMCP, test_image_path, tmp_path):\n        \"\"\"Tests making a polygonal area transparent with all channels set to 0.\"\"\"\n        output_path = str(tmp_path / \"output_transparent_poly.png\")\n        fill_area = {\"polygon\": [[160, 110], [240, 110], [200, 190]], \"color\": None}\n\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"fill\",\n                {\n                    \"input_path\": test_image_path,\n                    \"areas\": [fill_area],\n                    \"output_path\": output_path,\n                },\n            )\n            assert result.data == output_path\n            assert os.path.exists(output_path)\n\n            img = cv2.imread(output_path, cv2.IMREAD_UNCHANGED)\n            assert img.shape[2] == 4  # Should have alpha channel\n\n            # Check pixels inside the transparent polygon - all channels should be 0\n            test_points = [\n                (140, 200),  # Center of polygon\n                (170, 200),  # Another point inside\n                (150, 180),  # Another point inside\n            ]\n            \n            for y, x in test_points:\n                pixel = img[y, x]\n                assert np.array_equal(pixel, [0, 0, 0, 0]), f\"Pixel at ({y}, {x}) inside polygon should have all channels set to 0\"\n\n            # Check a pixel outside the transparent area\n            pixel_outside = img[50, 50]\n            assert pixel_outside[3] == 255  # Alpha should be 255\n            assert np.array_equal(pixel_outside[:3], [255, 255, 255])  # Should be white\n    \n    @pytest.mark.asyncio\n    async def test_fill_invert_rectangle(self, mcp_server: FastMCP, test_image_path, tmp_path):\n        \"\"\"Tests the fill tool with invert_areas for a rectangle.\"\"\"\n        output_path = str(tmp_path / \"output_inverted.png\")\n        \n        # Define a rectangle in the center (where the black rectangle is)\n        fill_area = {\"x1\": 150, \"y1\": 100, \"x2\": 250, \"y2\": 200, \"color\": [0, 255, 0], \"opacity\": 1.0}\n        \n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"fill\", \n                {\n                    \"input_path\": test_image_path, \n                    \"areas\": [fill_area], \n                    \"invert_areas\": True,\n                    \"output_path\": output_path\n                }\n            )\n            assert result.data == output_path\n            assert os.path.exists(output_path)\n\n            img = cv2.imread(output_path)\n            \n            # Center pixel (inside the specified area) should NOT be filled - remains original\n            center_pixel = img[150, 200]\n            assert np.array_equal(center_pixel, [0, 0, 0])  # Should remain black (original)\n            \n            # Pixels outside the area should be filled with green\n            outside_pixel = img[50, 50]\n            assert np.allclose(outside_pixel, [0, 255, 0], atol=2)  # Should be green - allow tolerance for JPEG\n            \n            # Another outside pixel\n            edge_pixel = img[250, 350]\n            assert np.array_equal(edge_pixel, [0, 255, 0])  # Should be green\n\n    @pytest.mark.asyncio\n    async def test_fill_invert_polygon(self, mcp_server: FastMCP, test_image_path, tmp_path):\n        \"\"\"Tests the fill tool with invert_areas for a polygon.\"\"\"\n        output_path = str(tmp_path / \"output_inverted_poly.png\")\n        \n        # Define a triangle polygon\n        polygon_area = {\"polygon\": [[160, 110], [240, 110], [200, 190]], \"color\": [255, 0, 0], \"opacity\": 0.8}\n        \n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"fill\",\n                {\n                    \"input_path\": test_image_path,\n                    \"areas\": [polygon_area],\n                    \"invert_areas\": True,\n                    \"output_path\": output_path\n                }\n            )\n            assert result.data == output_path\n            assert os.path.exists(output_path)\n\n            img = cv2.imread(output_path)\n            \n            # Center of polygon (inside the specified area) should NOT be filled\n            poly_center = img[150, 200]\n            assert np.array_equal(poly_center, [0, 0, 0])  # Should remain black\n            \n            # Outside pixels should be filled with blue at 80% opacity\n            # Since original is white [255,255,255], and we're applying blue [255,0,0] at 80% opacity:\n            # Result = 0.8 * [255,0,0] + 0.2 * [255,255,255] = [255, 51, 51] (approximately)\n            outside_pixel = img[50, 50]\n            assert np.allclose(outside_pixel, [255, 51, 51], atol=2)  # 80% blue over white\n\n    @pytest.mark.asyncio\n    async def test_fill_invert_transparent(self, mcp_server: FastMCP, test_image_path, tmp_path):\n        \"\"\"Tests making everything except a rectangle transparent (background removal) with all channels set to 0.\"\"\"\n        output_path = str(tmp_path / \"output_bg_removed.png\")\n        \n        # Keep only the center rectangle, make everything else transparent\n        keep_area = {\"x1\": 150, \"y1\": 100, \"x2\": 250, \"y2\": 200, \"color\": None}\n        \n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"fill\",\n                {\n                    \"input_path\": test_image_path,\n                    \"areas\": [keep_area],\n                    \"invert_areas\": True,\n                    \"output_path\": output_path\n                }\n            )\n            assert result.data == output_path\n            assert os.path.exists(output_path)\n\n            img = cv2.imread(output_path, cv2.IMREAD_UNCHANGED)\n            assert img.shape[2] == 4  # Should have alpha channel\n            \n            # Inside the kept area - should be opaque (not modified)\n            inside_pixel = img[150, 200]\n            assert inside_pixel[3] == 255  # Alpha should be 255 (opaque)\n            assert np.array_equal(inside_pixel[:3], [0, 0, 0])  # Color preserved\n            \n            # Outside the kept area - should be fully transparent (all channels 0)\n            outside_pixels = [\n                (50, 50),    # Top left\n                (250, 350),  # Bottom right\n                (10, 10),    # Corner\n            ]\n            \n            for y, x in outside_pixels:\n                pixel = img[y, x]\n                assert np.array_equal(pixel, [0, 0, 0, 0]), f\"Pixel at ({y}, {x}) outside kept area should have all channels set to 0\"\n\n    @pytest.mark.asyncio\n    async def test_fill_invert_multiple_areas(self, mcp_server: FastMCP, test_image_path, tmp_path):\n        \"\"\"Tests invert_areas with multiple areas to keep.\"\"\"\n        output_path = str(tmp_path / \"output_multi_keep.png\")\n        \n        # Keep two areas, fill everything else\n        areas = [\n            {\"x1\": 50, \"y1\": 50, \"x2\": 100, \"y2\": 100, \"color\": [0, 0, 255], \"opacity\": 1.0},\n            {\"x1\": 200, \"y1\": 150, \"x2\": 250, \"y2\": 200}  # Will use first area's color\n        ]\n        \n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"fill\",\n                {\n                    \"input_path\": test_image_path,\n                    \"areas\": areas,\n                    \"invert_areas\": True,\n                    \"output_path\": output_path\n                }\n            )\n            assert result.data == output_path\n            assert os.path.exists(output_path)\n\n            img = cv2.imread(output_path)\n            \n            # First kept area should NOT be filled (remains original)\n            kept_pixel1 = img[75, 75]\n            assert np.array_equal(kept_pixel1, [255, 255, 255])  # Should remain white\n            \n            # Second kept area should NOT be filled (remains original)\n            kept_pixel2 = img[175, 225]\n            assert np.array_equal(kept_pixel2, [0, 0, 0])  # Should remain black\n            \n            # Area between them should be filled with blue\n            between_pixel = img[125, 150]\n            assert np.array_equal(between_pixel, [0, 0, 255])  # Should be blue (BGR format)\n\n    @pytest.mark.asyncio\n    async def test_fill_invert_complex_polygon(self, mcp_server: FastMCP, test_image_path, tmp_path):\n        \"\"\"Tests invert_areas with a complex polygon shape to keep.\"\"\"\n        output_path = str(tmp_path / \"output_complex_keep.png\")\n        \n        # Create a star-like polygon\n        star_polygon = {\n            \"polygon\": [\n                [200, 50], [220, 100], [270, 100], [230, 130],\n                [250, 180], [200, 150], [150, 180], [170, 130],\n                [130, 100], [180, 100]\n            ],\n            \"color\": None  # Make background transparent\n        }\n        \n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"fill\",\n                {\n                    \"input_path\": test_image_path,\n                    \"areas\": [star_polygon],\n                    \"invert_areas\": True,\n                    \"output_path\": output_path\n                }\n            )\n            assert result.data == output_path\n            assert os.path.exists(output_path)\n\n            img = cv2.imread(output_path, cv2.IMREAD_UNCHANGED)\n            assert img.shape[2] == 4  # Should have alpha channel\n            \n            # Center of star should be opaque (not modified)\n            star_center = img[115, 200]\n            assert star_center[3] == 255  # Should be opaque\n            \n            # Outside corners should be transparent\n            corner_pixel = img[10, 10]\n            assert corner_pixel[3] == 0  # Should be transparent\n\n    @pytest.mark.asyncio\n    async def test_fill_invert_single_area_transparent(self, mcp_server: FastMCP, test_image_path, tmp_path):\n        \"\"\"Tests a simple background removal use case - keep single object, remove background.\"\"\"\n        output_path = str(tmp_path / \"object_only.png\")\n        \n        # Define object boundaries\n        object_area = {\"x1\": 100, \"y1\": 80, \"x2\": 300, \"y2\": 220, \"color\": None}\n        \n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"fill\",\n                {\n                    \"input_path\": test_image_path,\n                    \"areas\": [object_area],\n                    \"invert_areas\": True,\n                    \"output_path\": output_path\n                }\n            )\n            assert result.data == output_path\n            assert os.path.exists(output_path)\n\n            img = cv2.imread(output_path, cv2.IMREAD_UNCHANGED)\n            \n            # Check that image has alpha channel\n            assert img.shape[2] == 4\n            \n            # Inside object area should be opaque\n            object_pixel = img[150, 200]\n            assert object_pixel[3] == 255\n            \n            # Outside object area should be transparent  \n            bg_pixel = img[10, 10]\n            assert bg_pixel[3] == 0\n            \n            # Edge case - just outside the object\n            edge_pixel = img[79, 150]  # Just above the object\n            assert edge_pixel[3] == 0\n\n    @pytest.mark.asyncio\n    async def test_fill_with_mask_path(self, mcp_server: FastMCP, test_image_path, tmp_path):\n        \"\"\"Tests filling an area using a mask from a file.\"\"\"\n        output_path = str(tmp_path / \"output_mask_fill.png\")\n        mask_path = str(tmp_path / \"test_mask.png\")\n\n        # Create a mask image (e.g., a circle)\n        mask_img = np.zeros((300, 400), dtype=np.uint8)\n        cv2.circle(mask_img, (150, 150), 50, 255, -1)\n        cv2.imwrite(mask_path, mask_img)\n\n        fill_area = {\"mask_path\": mask_path, \"color\": [0, 255, 255], \"opacity\": 1.0}\n\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"fill\",\n                {\n                    \"input_path\": test_image_path,\n                    \"areas\": [fill_area],\n                    \"output_path\": output_path,\n                },\n            )\n            assert result.data == output_path\n            assert os.path.exists(output_path)\n\n            img = cv2.imread(output_path)\n            # Check a pixel inside the masked area\n            assert np.array_equal(img[150, 150], [0, 255, 255])\n            # Check a pixel outside the masked area\n            assert np.array_equal(img[50, 50], [255, 255, 255])\n\n\nclass TestFillToolWithJPEG:\n    \"\"\"Tests for the fill tool with JPEG images (no alpha channel).\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_fill_jpeg_to_transparent_rectangle(self, mcp_server: FastMCP, test_jpeg_image_path, tmp_path):\n        \"\"\"Tests making a rectangular area transparent in a JPEG image with all channels set to 0.\"\"\"\n        output_path = str(tmp_path / \"output_transparent.png\")  # Output as PNG to support transparency\n        fill_area = {\"x1\": 150, \"y1\": 100, \"x2\": 250, \"y2\": 200, \"color\": None}\n\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"fill\",\n                {\n                    \"input_path\": test_jpeg_image_path,\n                    \"areas\": [fill_area],\n                    \"output_path\": output_path,\n                },\n            )\n            assert result.data == output_path\n            assert os.path.exists(output_path)\n\n            img = cv2.imread(output_path, cv2.IMREAD_UNCHANGED)\n            assert img.shape[2] == 4  # Should have alpha channel added\n\n            # Check a pixel inside the transparent area - all channels should be 0\n            pixel_inside = img[150, 200]\n            assert np.array_equal(pixel_inside, [0, 0, 0, 0]), \"All BGRA channels should be 0 for transparent areas\"\n\n            # Check a pixel outside the transparent area\n            pixel_outside = img[50, 50]\n            assert pixel_outside[3] == 255  # Alpha should be 255\n            assert np.array_equal(pixel_outside[:3], [255, 255, 255])  # Should be white\n\n    @pytest.mark.asyncio\n    async def test_fill_jpeg_invert_transparent(self, mcp_server: FastMCP, test_jpeg_image_path, tmp_path):\n        \"\"\"Tests making everything except a rectangle transparent in a JPEG image (background removal) with all channels set to 0.\"\"\"\n        output_path = str(tmp_path / \"output_bg_removed.png\")\n        \n        # Keep only the center rectangle, make everything else transparent\n        keep_area = {\"x1\": 150, \"y1\": 100, \"x2\": 250, \"y2\": 200, \"color\": None}\n        \n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"fill\",\n                {\n                    \"input_path\": test_jpeg_image_path,\n                    \"areas\": [keep_area],\n                    \"invert_areas\": True,\n                    \"output_path\": output_path\n                }\n            )\n            assert result.data == output_path\n            assert os.path.exists(output_path)\n\n            img = cv2.imread(output_path, cv2.IMREAD_UNCHANGED)\n            assert img.shape[2] == 4  # Should have alpha channel\n            \n            # Inside the kept area - should be opaque (not modified)\n            inside_pixel = img[150, 200]\n            assert inside_pixel[3] == 255  # Alpha should be 255 (opaque)\n            # Check the red circle is preserved (use allclose due to JPEG compression)\n            circle_center = img[150, 200]\n            assert circle_center[3] == 255  # Should be opaque\n            assert np.allclose(circle_center[:3], [0, 0, 255], atol=2)  # Should be red (BGR) - allow tolerance for JPEG\n            \n            # Outside the kept area - should be fully transparent (all channels 0)\n            outside_pixels = [\n                (50, 50),    # Top left\n                (10, 10),    # Corner\n                (250, 350),  # Bottom right\n            ]\n            \n            for y, x in outside_pixels:\n                pixel = img[y, x]\n                assert np.array_equal(pixel, [0, 0, 0, 0]), f\"Pixel at ({y}, {x}) outside kept area should have all channels set to 0\"\n\n    @pytest.mark.asyncio\n    async def test_fill_jpeg_invert_with_color(self, mcp_server: FastMCP, test_jpeg_image_path, tmp_path):\n        \"\"\"Tests invert_areas with color fill on a JPEG image.\"\"\"\n        output_path = str(tmp_path / \"output_inverted_color.jpg\")  # Keep as JPEG\n        \n        # Define a rectangle in the center\n        fill_area = {\"x1\": 150, \"y1\": 100, \"x2\": 250, \"y2\": 200, \"color\": [0, 255, 0], \"opacity\": 1.0}\n        \n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"fill\", \n                {\n                    \"input_path\": test_jpeg_image_path, \n                    \"areas\": [fill_area], \n                    \"invert_areas\": True,\n                    \"output_path\": output_path\n                }\n            )\n            assert result.data == output_path\n            assert os.path.exists(output_path)\n\n            img = cv2.imread(output_path)\n            \n            # Center pixel (inside the specified area) should NOT be filled\n            center_pixel = img[150, 200]\n            assert np.allclose(center_pixel, [0, 0, 255], atol=2)  # Should remain red - allow tolerance for JPEG\n            \n            # Pixels outside the area should be filled with green\n            outside_pixel = img[50, 50]\n            assert np.allclose(outside_pixel, [0, 255, 0], atol=2)  # Should be green - allow tolerance for JPEG\n\n    @pytest.mark.asyncio \n    async def test_fill_jpeg_multiple_transparent_areas(self, mcp_server: FastMCP, test_jpeg_image_path, tmp_path):\n        \"\"\"Tests multiple transparent areas on a JPEG image with all channels set to 0.\"\"\"\n        output_path = str(tmp_path / \"output_multi_transparent.png\")\n        \n        areas = [\n            {\"x1\": 50, \"y1\": 50, \"x2\": 100, \"y2\": 100, \"color\": None},\n            {\"polygon\": [[250, 150], [350, 150], [300, 250]], \"color\": None}\n        ]\n        \n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"fill\",\n                {\n                    \"input_path\": test_jpeg_image_path,\n                    \"areas\": areas,\n                    \"output_path\": output_path\n                }\n            )\n            assert result.data == output_path\n            assert os.path.exists(output_path)\n\n            img = cv2.imread(output_path, cv2.IMREAD_UNCHANGED)\n            assert img.shape[2] == 4  # Should have alpha channel\n            \n            # First transparent area - all channels should be 0\n            pixel_area1 = img[75, 75]\n            assert np.array_equal(pixel_area1, [0, 0, 0, 0]), \"First transparent area should have all channels set to 0\"\n            \n            # Second transparent area (inside polygon) - all channels should be 0\n            pixel_area2 = img[180, 300]\n            assert np.array_equal(pixel_area2, [0, 0, 0, 0]), \"Second transparent area should have all channels set to 0\"\n            \n            # Non-transparent area\n            pixel_normal = img[150, 200]\n            assert pixel_normal[3] == 255  # Should be opaque\n            assert np.allclose(pixel_normal[:3], [0, 0, 255], atol=2)  # Should be red (from the circle)\n"
  },
  {
    "path": "tests/tools/test_find.py",
    "content": "import os\nimport shutil\n\nimport cv2\nimport numpy as np\nimport pytest\nfrom fastmcp import Client, FastMCP\nfrom PIL import Image, ImageDraw\n\nfrom imagesorcery_mcp.server import mcp as image_sorcery_mcp_server\n\n\n@pytest.fixture\ndef mcp_server():\n    # Use the existing server instance\n    return image_sorcery_mcp_server\n\n\n@pytest.fixture\ndef test_image_path(tmp_path):\n    \"\"\"Path to a test image with known objects for finding.\"\"\"\n    # Path to the test image in the tests/data directory\n    current_dir = os.path.dirname(os.path.abspath(__file__))\n    test_data_dir = os.path.join(os.path.dirname(current_dir), \"data\")\n    src_path = os.path.join(test_data_dir, \"test_detection.jpg\")\n    dest_path = tmp_path / \"test_detection.jpg\"\n    shutil.copy(src_path, dest_path)\n    return str(dest_path)\n\n\n@pytest.fixture\ndef test_segmentation_image_path(tmp_path):\n    \"\"\"Path to a simple test image for segmentation mask validation.\"\"\"\n    current_dir = os.path.dirname(os.path.abspath(__file__))\n    test_data_dir = os.path.join(os.path.dirname(current_dir), \"data\")\n    src_path = os.path.join(test_data_dir, \"test_detection_mask.jpg\")\n    dest_path = tmp_path / \"test_detection_mask.jpg\"\n    shutil.copy(src_path, dest_path)\n    return str(dest_path)\n\n\nclass TestFindToolDefinition:\n    \"\"\"Tests for the find tool definition and metadata.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_find_in_tools_list(self, mcp_server: FastMCP):\n        \"\"\"Tests that find tool is in the list of available tools.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            # Verify that tools list is not empty\n            assert tools, \"Tools list should not be empty\"\n\n            # Check if find is in the list of tools\n            tool_names = [tool.name for tool in tools]\n            assert \"find\" in tool_names, (\n                \"find tool should be in the list of available tools\"\n            )\n\n    @pytest.mark.asyncio\n    async def test_find_description(self, mcp_server: FastMCP):\n        \"\"\"Tests that find tool has the correct description.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            find_tool = next((tool for tool in tools if tool.name == \"find\"), None)\n\n            # Check description\n            assert find_tool.description, \"find tool should have a description\"\n            assert \"find\" in find_tool.description.lower(), (\n                \"Description should mention that it finds objects in an image\"\n            )\n\n    @pytest.mark.asyncio\n    async def test_find_parameters(self, mcp_server: FastMCP):\n        \"\"\"Tests that find tool has the correct parameter structure.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            find_tool = next((tool for tool in tools if tool.name == \"find\"), None)\n\n            # Check input schema\n            assert hasattr(find_tool, \"inputSchema\"), (\n                \"find tool should have an inputSchema\"\n            )\n            assert \"properties\" in find_tool.inputSchema, (\n                \"inputSchema should have properties field\"\n            )\n\n            # Check required parameters\n            required_params = [\"input_path\", \"description\"]\n            for param in required_params:\n                assert param in find_tool.inputSchema[\"properties\"], (\n                    f\"find tool should have a '{param}' property in its inputSchema\"\n                )\n\n            # Check optional parameters\n            optional_params = [\"confidence\", \"model_name\", \"return_all_matches\", \"return_geometry\", \"geometry_format\"]\n            for param in optional_params:\n                assert param in find_tool.inputSchema[\"properties\"], (\n                    f\"find tool should have a '{param}' property in its inputSchema\"\n                )\n\n            # Check parameter types and defaults\n            assert (\n                find_tool.inputSchema[\"properties\"][\"input_path\"].get(\"type\")\n                == \"string\"\n            ), \"input_path should be of type string\"\n            assert (\n                find_tool.inputSchema[\"properties\"][\"description\"].get(\"type\")\n                == \"string\"\n            ), \"description should be of type string\"\n\n            # Check optional parameters (now have anyOf structure with null)\n            confidence_schema = find_tool.inputSchema[\"properties\"][\"confidence\"]\n            assert \"anyOf\" in confidence_schema, \"confidence should have anyOf structure for optional parameter\"\n            assert any(item.get(\"type\") == \"number\" for item in confidence_schema[\"anyOf\"]), \"confidence should allow number type\"\n            assert any(item.get(\"type\") == \"null\" for item in confidence_schema[\"anyOf\"]), \"confidence should allow null type\"\n\n            model_name_schema = find_tool.inputSchema[\"properties\"][\"model_name\"]\n            assert \"anyOf\" in model_name_schema, \"model_name should have anyOf structure for optional parameter\"\n            assert any(item.get(\"type\") == \"string\" for item in model_name_schema[\"anyOf\"]), \"model_name should allow string type\"\n            assert any(item.get(\"type\") == \"null\" for item in model_name_schema[\"anyOf\"]), \"model_name should allow null type\"\n            assert (\n                find_tool.inputSchema[\"properties\"][\"return_all_matches\"].get(\"type\")\n                == \"boolean\"\n            ), \"return_all_matches should be of type boolean\"\n\n            # New parameters for geometry\n            assert (\n                find_tool.inputSchema[\"properties\"][\"return_geometry\"].get(\"type\")\n                == \"boolean\"\n            ), \"return_geometry should be of type boolean\"\n            assert (\n                find_tool.inputSchema[\"properties\"][\"return_geometry\"].get(\"default\")\n                is False\n            ), \"return_geometry default should be False\"\n\n            assert (\n                find_tool.inputSchema[\"properties\"][\"geometry_format\"].get(\"type\")\n                == \"string\"\n            ), \"geometry_format should be of type string\"\n            assert (\n                find_tool.inputSchema[\"properties\"][\"geometry_format\"].get(\"enum\")\n                == [\"mask\", \"polygon\"]\n            ), \"geometry_format enum should be ['mask', 'polygon']\"\n            assert (\n                find_tool.inputSchema[\"properties\"][\"geometry_format\"].get(\"default\")\n                == \"mask\"\n            ), \"geometry_format default should be 'mask'\"\n\n\nclass TestFindToolExecution:\n    \"\"\"Tests for the find tool execution and results.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.skipif(\n        os.environ.get(\"SKIP_YOLO_TESTS\") == \"1\",\n        reason=\"Skipping YOLO tests to avoid downloading models in CI\",\n    )\n    async def test_find_tool_execution(self, mcp_server: FastMCP, test_image_path):\n        \"\"\"Tests the find tool execution and return value.\"\"\"\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"find\",\n                {\n                    \"input_path\": test_image_path,\n                    \"description\": \"car\",\n                    \"confidence\": 0.25,\n                    \"model_name\": \"yoloe-11s-seg.pt\",\n                    \"return_all_matches\": True,\n                },\n            )\n\n            # Parse the result\n            find_result = result.structured_content\n\n            # Basic structure checks\n            assert \"image_path\" in find_result, \"Result should contain image_path\"\n            assert \"query\" in find_result, \"Result should contain query\"\n            assert \"found_objects\" in find_result, \"Result should contain found_objects\"\n            assert \"found\" in find_result, \"Result should contain found flag\"\n            assert find_result[\"image_path\"] == test_image_path, \"Image path should match input path\"\n            assert find_result[\"query\"] == \"car\", \"Query should match input description\"\n            assert isinstance(find_result[\"found_objects\"], list), \"found_objects should be a list\"\n            \n            # Verify that at least one object was found (the test image has 2 people)\n            assert find_result[\"found\"] is True, \"Should have found at least one car in the test image\"\n            assert len(find_result[\"found_objects\"]) > 0, \"Should have found at least one car in the test image\"\n            \n            # Check the structure of each found object\n            for found_object in find_result[\"found_objects\"]:\n                assert \"description\" in found_object, \"Found object should have description\"\n                assert \"match\" in found_object, \"Found object should have match\"\n                assert \"confidence\" in found_object, \"Found object should have confidence\"\n                assert \"bbox\" in found_object, \"Found object should have bbox\"\n                \n                # Check that confidence is within expected range\n                assert 0 <= found_object[\"confidence\"] <= 1, \"Confidence should be between 0 and 1\"\n                \n                # Check that the bounding box has 4 coordinates\n                assert len(found_object[\"bbox\"]) == 4, \"Bounding box should have 4 coordinates\"\n                \n                # Check that the description matches the query\n                assert found_object[\"description\"] == \"car\", \"Description should match the query\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.skipif(\n        os.environ.get(\"SKIP_YOLO_TESTS\") == \"1\",\n        reason=\"Skipping YOLO tests to avoid downloading models in CI\",\n    )\n    async def test_find_single_result(self, mcp_server: FastMCP, test_image_path):\n        \"\"\"Tests that the find tool returns only the best match when return_all_matches is False.\"\"\"\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"find\",\n                {\n                    \"input_path\": test_image_path,\n                    \"description\": \"car\",\n                    \"confidence\": 0.25,\n                    \"model_name\": \"yoloe-11s-seg.pt\",\n                    \"return_all_matches\": False,\n                },\n            )\n\n            # Parse the result\n            find_result = result.structured_content\n            \n            # Verify that exactly one car was found when return_all_matches is False\n            assert find_result[\"found\"] is True, \"Should have found a car in the test image\"\n            assert len(find_result[\"found_objects\"]) == 1, \"Should have returned exactly one car when return_all_matches is False\"\n            \n            # Check the structure of the found object\n            found_object = find_result[\"found_objects\"][0]\n            assert \"description\" in found_object, \"Found object should have description\"\n            assert \"match\" in found_object, \"Found object should have match\"\n            assert \"confidence\" in found_object, \"Found object should have confidence\"\n            assert \"bbox\" in found_object, \"Found object should have bbox\"\n            \n            # Check that confidence is within expected range\n            assert 0 <= found_object[\"confidence\"] <= 1, \"Confidence should be between 0 and 1\"\n            \n            # Check that the bounding box has 4 coordinates\n            assert len(found_object[\"bbox\"]) == 4, \"Bounding box should have 4 coordinates\"\n            \n            # Check that the description matches the query\n            assert found_object[\"description\"] == \"car\", \"Description should match the query\"\n                \n    @pytest.mark.asyncio\n    @pytest.mark.skipif(\n        os.environ.get(\"SKIP_YOLO_TESTS\") == \"1\",\n        reason=\"Skipping YOLO tests to avoid downloading models in CI\",\n    )\n    async def test_find_nonexistent_object(self, mcp_server: FastMCP, test_image_path):\n        \"\"\"Tests that the find tool correctly handles searching for objects that don't exist.\"\"\"\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"find\",\n                {\n                    \"input_path\": test_image_path,\n                    \"description\": \"unicorn\",  # Something unlikely to be in the test image\n                    \"confidence\": 0.25,\n                    \"model_name\": \"yoloe-11s-seg.pt\",\n                },\n            )\n\n            # Parse the result\n            find_result = result.structured_content\n            \n            # Check the structure of the result\n            assert \"image_path\" in find_result, \"Result should contain image_path\"\n            assert \"query\" in find_result, \"Result should contain query\"\n            assert \"found_objects\" in find_result, \"Result should contain found_objects\"\n            assert \"found\" in find_result, \"Result should contain found flag\"\n            \n            # The found flag should be False if no objects were found\n            if len(find_result[\"found_objects\"]) == 0:\n                assert find_result[\"found\"] is False, \"found flag should be False when no objects are found\"\n            \n            # The query should match what we searched for\n            assert find_result[\"query\"] == \"unicorn\", \"Query should match input description\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.skipif(\n        os.environ.get(\"SKIP_YOLO_TESTS\") == \"1\",\n        reason=\"Skipping YOLO tests to avoid downloading models in CI\",\n    )\n    async def test_find_with_mask_geometry(self, mcp_server: FastMCP, test_image_path):\n        \"\"\"Tests the find tool with mask geometry return.\"\"\"\n        if not os.path.exists(test_image_path):\n            pytest.skip(f\"Test image not found at {test_image_path}\")\n\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"find\",\n                {\n                    \"input_path\": test_image_path,\n                    \"description\": \"car\",\n                    \"model_name\": \"yoloe-11s-seg.pt\",\n                    \"return_geometry\": True,\n                    \"geometry_format\": \"mask\",\n                    \"confidence\": 0.25,\n                },\n            )\n            find_result = result.structured_content\n            assert find_result[\"found\"]\n            assert len(find_result[\"found_objects\"]) > 0\n            found_object = find_result[\"found_objects\"][0]\n            assert \"mask_path\" in found_object\n            assert \"polygon\" not in found_object\n            mask_path = found_object[\"mask_path\"]\n            assert isinstance(mask_path, str)\n            assert os.path.exists(mask_path)\n\n    @pytest.mark.asyncio\n    @pytest.mark.skipif(\n        os.environ.get(\"SKIP_YOLO_TESTS\") == \"1\",\n        reason=\"Skipping YOLO tests to avoid downloading models in CI\",\n    )\n    async def test_find_with_polygon_geometry(self, mcp_server: FastMCP, test_image_path):\n        \"\"\"Tests the find tool with polygon geometry return.\"\"\"\n        if not os.path.exists(test_image_path):\n            pytest.skip(f\"Test image not found at {test_image_path}\")\n\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"find\",\n                {\n                    \"input_path\": test_image_path,\n                    \"description\": \"car\",\n                    \"model_name\": \"yoloe-11s-seg.pt\",\n                    \"return_geometry\": True,\n                    \"geometry_format\": \"polygon\",\n                    \"confidence\": 0.25,\n                },\n            )\n            find_result = result.structured_content\n            assert find_result[\"found\"]\n            assert len(find_result[\"found_objects\"]) > 0\n            found_object = find_result[\"found_objects\"][0]\n            assert \"polygon\" in found_object\n            assert \"mask\" not in found_object\n            polygon_data = found_object[\"polygon\"]\n            assert isinstance(polygon_data, list)\n            assert len(polygon_data) > 0\n            assert isinstance(polygon_data[0], list)\n            assert len(polygon_data[0]) == 2\n\n    @pytest.mark.asyncio\n    @pytest.mark.skipif(\n        os.environ.get(\"SKIP_YOLO_TESTS\") == \"1\",\n        reason=\"Skipping YOLO tests to avoid downloading models in CI\",\n    )\n    async def test_find_no_geometry_by_default(self, mcp_server: FastMCP, test_image_path):\n        \"\"\"Tests that find tool returns no geometry by default.\"\"\"\n        if not os.path.exists(test_image_path):\n            pytest.skip(f\"Test image not found at {test_image_path}\")\n\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"find\",\n                {\n                    \"input_path\": test_image_path,\n                    \"description\": \"car\",\n                    \"model_name\": \"yoloe-11s-seg.pt\",\n                    \"confidence\": 0.25,\n                },\n            )\n            find_result = result.structured_content\n            assert find_result[\"found\"]\n            assert len(find_result[\"found_objects\"]) > 0\n            found_object = find_result[\"found_objects\"][0]\n            assert \"mask_path\" not in found_object\n            assert \"polygon\" not in found_object\n\n    @pytest.mark.asyncio\n    @pytest.mark.skipif(\n        os.environ.get(\"SKIP_YOLO_TESTS\") == \"1\",\n        reason=\"Skipping YOLO tests to avoid downloading models in CI\",\n    )\n    async def test_mask_correctness(self, mcp_server: FastMCP, test_image_path):\n        \"\"\"Tests that returned masks are valid and correctly positioned.\"\"\"\n        with Image.open(test_image_path) as img:\n            orig_width, orig_height = img.size\n\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\"find\", {\"input_path\": test_image_path, \"description\": \"car\", \"model_name\": \"yoloe-11s-seg.pt\", \"return_geometry\": True, \"geometry_format\": \"mask\", \"confidence\": 0.25})\n            \n            find_result = result.structured_content\n            assert find_result[\"found\"]\n            \n            for obj in find_result[\"found_objects\"]:\n                assert \"mask_path\" in obj\n                mask_path = obj[\"mask_path\"]\n                assert os.path.exists(mask_path)\n\n                mask = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE)\n                assert mask is not None\n\n                bbox = obj[\"bbox\"]\n                x1, y1, x2, y2 = bbox\n                \n                mask_height, mask_width = mask.shape\n                \n                assert (\n                    (mask_height == mask_width) or\n                    (mask_height == orig_height and mask_width == orig_width)\n                ), f\"Mask dimensions {mask.shape} should be square or match original image\"\n\n                scale_x = orig_width / mask_width\n                scale_y = orig_height / mask_height\n\n                unique_values = np.unique(mask)\n                assert len(unique_values) <= 2\n                assert all(v in [0, 255] for v in unique_values)\n\n                assert np.sum(mask) > 0\n\n                mask_indices = np.where(mask > 0)\n                if len(mask_indices[0]) > 0:\n                    min_y, max_y = mask_indices[0].min(), mask_indices[0].max()\n                    min_x, max_x = mask_indices[1].min(), mask_indices[1].max()\n\n                    scaled_x1 = x1 / scale_x\n                    scaled_x2 = x2 / scale_x\n                    scaled_y1 = y1 / scale_y\n                    scaled_y2 = y2 / scale_y\n\n                    tolerance = 10\n                    assert min_x >= scaled_x1 - tolerance\n                    assert max_x <= scaled_x2 + tolerance\n                    assert min_y >= scaled_y1 - tolerance\n                    assert max_y <= scaled_y2 + tolerance\n\n                mask_area = np.sum(mask > 0)\n                scaled_bbox_area = ((scaled_x2 - scaled_x1) * (scaled_y2 - scaled_y1))\n                coverage_ratio = mask_area / scaled_bbox_area if scaled_bbox_area > 0 else 0\n\n                assert 0.1 <= coverage_ratio <= 1.5\n\n    @pytest.mark.asyncio\n    @pytest.mark.skipif(\n        os.environ.get(\"SKIP_YOLO_TESTS\") == \"1\",\n        reason=\"Skipping YOLO tests to avoid downloading models in CI\",\n    )\n    async def test_polygon_correctness(self, mcp_server: FastMCP, test_image_path):\n        \"\"\"Tests that returned polygons are valid and correctly positioned.\"\"\"\n        # Load the test image to get its dimensions\n        with Image.open(test_image_path) as img:\n            img_width, img_height = img.size\n\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"find\",\n                {\n                    \"input_path\": test_image_path,\n                    \"description\": \"car\",\n                    \"model_name\": \"yoloe-11s-seg.pt\",\n                    \"return_geometry\": True,\n                    \"geometry_format\": \"polygon\",\n                    \"confidence\": 0.25,\n                },\n            )\n            \n            find_result = result.structured_content\n            assert find_result[\"found\"]\n            \n            for obj in find_result[\"found_objects\"]:\n                polygon = obj[\"polygon\"]\n                bbox = obj[\"bbox\"]\n                x1, y1, x2, y2 = bbox\n                \n                # 1. Check polygon has at least 3 points\n                assert len(polygon) >= 3, \"Polygon should have at least 3 points\"\n                \n                # 2. Check all points have exactly 2 coordinates\n                for point in polygon:\n                    assert len(point) == 2, f\"Each polygon point should have 2 coordinates, got {len(point)}\"\n                \n                # 3. Check all coordinates are reasonable\n                # Note: Polygon coordinates should be in original image space\n                for x, y in polygon:\n                    # Allow some tolerance outside image bounds\n                    tolerance = 10\n                    assert -tolerance <= x <= img_width + tolerance, (\n                        f\"X coordinate {x} should be within image width {img_width} (with tolerance)\"\n                    )\n                    assert -tolerance <= y <= img_height + tolerance, (\n                        f\"Y coordinate {y} should be within image height {img_height} (with tolerance)\"\n                    )\n                \n                # 4. Check polygon points are within bbox bounds (with tolerance)\n                tolerance = 10\n                xs = [p[0] for p in polygon]\n                ys = [p[1] for p in polygon]\n                \n                assert min(xs) >= x1 - tolerance, f\"Min polygon x {min(xs)} should be >= bbox x1 {x1}\"\n                assert max(xs) <= x2 + tolerance, f\"Max polygon x {max(xs)} should be <= bbox x2 {x2}\"\n                assert min(ys) >= y1 - tolerance, f\"Min polygon y {min(ys)} should be >= bbox y1 {y1}\"\n                assert max(ys) <= y2 + tolerance, f\"Max polygon y {max(ys)} should be <= bbox y2 {y2}\"\n                \n                # 5. Check polygon area is positive (using shoelace formula)\n                area = 0\n                n = len(polygon)\n                for i in range(n):\n                    j = (i + 1) % n\n                    area += polygon[i][0] * polygon[j][1]\n                    area -= polygon[j][0] * polygon[i][1]\n                area = abs(area) / 2.0\n                \n                assert area > 0, \"Polygon area should be positive\"\n                \n                # 6. Check polygon area relative to bbox area\n                bbox_area = (x2 - x1) * (y2 - y1)\n                area_ratio = area / bbox_area\n                \n                assert 0.1 <= area_ratio <= 1.5, (\n                    f\"Polygon area ratio {area_ratio:.2f} should be reasonable relative to bbox\"\n                )\n\n    @pytest.mark.asyncio\n    @pytest.mark.skipif(\n        os.environ.get(\"SKIP_YOLO_TESTS\") == \"1\",\n        reason=\"Skipping YOLO tests to avoid downloading models in CI\",\n    )\n    async def test_mask_to_polygon_consistency(self, mcp_server: FastMCP, test_image_path):\n        \"\"\"Tests that mask and polygon representations are consistent for the same object.\"\"\"\n        with Image.open(test_image_path) as img:\n            orig_width, orig_height = img.size\n            \n        async with Client(mcp_server) as client:\n            mask_result = await client.call_tool(\"find\", {\"input_path\": test_image_path, \"description\": \"car\", \"model_name\": \"yoloe-11s-seg.pt\", \"return_geometry\": True, \"geometry_format\": \"mask\", \"confidence\": 0.5, \"return_all_matches\": False})\n            polygon_result = await client.call_tool(\"find\", {\"input_path\": test_image_path, \"description\": \"car\", \"model_name\": \"yoloe-11s-seg.pt\", \"return_geometry\": True, \"geometry_format\": \"polygon\", \"confidence\": 0.5, \"return_all_matches\": False})\n            \n            mask_data = mask_result.structured_content\n            polygon_data = polygon_result.structured_content\n            \n            if mask_data[\"found\"] and polygon_data[\"found\"]:\n                mask_obj = mask_data[\"found_objects\"][0]\n                polygon_obj = polygon_data[\"found_objects\"][0]\n\n                mask_path = mask_obj[\"mask_path\"]\n                mask = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE)\n\n                mask_bbox = mask_obj[\"bbox\"]\n                polygon_bbox = polygon_obj[\"bbox\"]\n\n                bbox_tolerance = 20\n                for i in range(4):\n                    assert abs(mask_bbox[i] - polygon_bbox[i]) < bbox_tolerance\n\n                polygon_points = polygon_obj[\"polygon\"]\n                mask_height, mask_width = mask.shape\n\n                img = Image.new('L', (mask_width, mask_height), 0)\n\n                scale_x = mask_width / orig_width\n                scale_y = mask_height / orig_height\n\n                scaled_polygon = [(p[0] * scale_x, p[1] * scale_y) for p in polygon_points]\n\n                ImageDraw.Draw(img).polygon(scaled_polygon, outline=1, fill=1)\n                polygon_mask = np.array(img)\n\n                mask_bool = mask > 0\n                polygon_mask_bool = polygon_mask > 0\n                intersection = np.logical_and(mask_bool, polygon_mask_bool).sum()\n                union = np.logical_or(mask_bool, polygon_mask_bool).sum()\n                iou = intersection / union if union > 0 else 0\n\n                assert iou > 0.5\n\n    @pytest.mark.asyncio\n    @pytest.mark.skipif(\n        os.environ.get(\"SKIP_YOLO_TESTS\") == \"1\",\n        reason=\"Skipping YOLO tests to avoid downloading models in CI\",\n    )\n    async def test_find_mask_validation_on_simple_image(\n        self, mcp_server: FastMCP, test_segmentation_image_path\n    ):\n        \"\"\"\n        Tests that generated masks from the find tool are valid using a simple image.\n        It checks for binarity and bounding box confinement for every generated mask.\n        \"\"\"\n        with Image.open(test_segmentation_image_path) as img:\n            orig_width, orig_height = img.size\n\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"find\",\n                {\n                    \"input_path\": test_segmentation_image_path,\n                    \"description\": \"dog\",\n                    \"model_name\": \"yoloe-11s-seg.pt\",\n                    \"return_geometry\": True,\n                    \"geometry_format\": \"mask\",\n                    \"confidence\": 0.3,\n                    \"return_all_matches\": True,\n                },\n            )\n\n            find_result = result.structured_content\n            assert find_result is not None\n            assert find_result[\"found\"], \"Should have found a dog in the image\"\n            assert len(find_result[\"found_objects\"]) >= 1, \"Should have found at least one dog\"\n\n            # Validate every mask that was generated\n            for found_object in find_result[\"found_objects\"]:\n                assert \"mask_path\" in found_object, \"Each found object should have a mask_path\"\n                mask_path = found_object[\"mask_path\"]\n                assert os.path.exists(mask_path), f\"Mask file should exist at {mask_path}\"\n\n                mask = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE)\n                assert mask is not None, f\"Mask file {mask_path} could not be read\"\n\n                # 1. Check for binarity (only 0 and 255 values)\n                unique_values = np.unique(mask)\n                assert all(v in [0, 255] for v in unique_values), (\n                    f\"Mask {mask_path} is not binary. Found values: {unique_values}\"\n                )\n                assert np.sum(mask) > 0, f\"Mask {mask_path} should not be empty\"\n\n                # 2. Check for bounding box confinement\n                bbox = found_object[\"bbox\"]\n                x1, y1, x2, y2 = bbox\n                mask_height, mask_width = mask.shape\n\n                scale_x = orig_width / mask_width\n                scale_y = orig_height / mask_height\n\n                mask_indices = np.where(mask > 0)\n                if len(mask_indices[0]) > 0:\n                    min_mask_y, max_mask_y = mask_indices[0].min(), mask_indices[0].max()\n                    min_mask_x, max_mask_x = mask_indices[1].min(), mask_indices[1].max()\n\n                    scaled_x1 = x1 / scale_x\n                    scaled_y1 = y1 / scale_y\n                    scaled_x2 = x2 / scale_x\n                    scaled_y2 = y2 / scale_y\n\n                    tolerance = 10\n                    assert min_mask_x >= scaled_x1 - tolerance, f\"Mask content of {mask_path} extends past the left of its bbox\"\n                    assert max_mask_x <= scaled_x2 + tolerance, f\"Mask content of {mask_path} extends past the right of its bbox\"\n                    assert min_mask_y >= scaled_y1 - tolerance, f\"Mask content of {mask_path} extends past the top of its bbox\"\n                    assert max_mask_y <= scaled_y2 + tolerance, f\"Mask content of {mask_path} extends past the bottom of its bbox\"\n"
  },
  {
    "path": "tests/tools/test_metainfo.py",
    "content": "\nimport pytest\nfrom fastmcp import Client, FastMCP\nfrom PIL import Image\n\nfrom imagesorcery_mcp.server import mcp as image_sorcery_mcp_server\n\n\n@pytest.fixture\ndef mcp_server():\n    # Use the existing server instance\n    return image_sorcery_mcp_server\n\n\n@pytest.fixture\ndef test_image_path(tmp_path):\n    \"\"\"Create a test image for metadata extraction.\"\"\"\n    img_path = tmp_path / \"test_image.png\"\n    img = Image.new(\"RGB\", (200, 200), color=\"white\")\n\n    # Draw some colored areas\n    for x in range(50, 100):\n        for y in range(50, 100):\n            img.putpixel((x, y), (255, 0, 0))  # Red square\n\n    img.save(img_path)\n    return str(img_path)\n\n\nclass TestMetainfoToolDefinition:\n    \"\"\"Tests for the get_metainfo tool definition and metadata.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_metainfo_in_tools_list(self, mcp_server: FastMCP):\n        \"\"\"Tests that get_metainfo tool is in the list of available tools.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            # Verify that tools list is not empty\n            assert tools, \"Tools list should not be empty\"\n\n            # Check if get_metainfo is in the list of tools\n            tool_names = [tool.name for tool in tools]\n            assert \"get_metainfo\" in tool_names, (\n                \"get_metainfo tool should be in the list of available tools\"\n            )\n\n    @pytest.mark.asyncio\n    async def test_metainfo_description(self, mcp_server: FastMCP):\n        \"\"\"Tests that get_metainfo tool has the correct description.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            metainfo_tool = next(\n                (tool for tool in tools if tool.name == \"get_metainfo\"), None\n            )\n\n            # Check description\n            assert metainfo_tool.description, (\n                \"get_metainfo tool should have a description\"\n            )\n            assert \"metadata\" in metainfo_tool.description.lower(), (\n                \"Description should mention metadata\"\n            )\n\n    @pytest.mark.asyncio\n    async def test_metainfo_parameters(self, mcp_server: FastMCP):\n        \"\"\"Tests that get_metainfo tool has the correct parameter structure.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            metainfo_tool = next(\n                (tool for tool in tools if tool.name == \"get_metainfo\"), None\n            )\n\n            # Check input schema\n            assert hasattr(metainfo_tool, \"inputSchema\"), (\n                \"get_metainfo tool should have an inputSchema\"\n            )\n            assert \"properties\" in metainfo_tool.inputSchema, (\n                \"inputSchema should have properties field\"\n            )\n\n            # Check required parameters\n            required_params = [\"input_path\"]\n            for param in required_params:\n                assert param in metainfo_tool.inputSchema[\"properties\"], (\n                    f\"get_metainfo tool should have a '{param}' property \"\n                    f\"in its inputSchema\"\n                )\n\n            # Check parameter types\n            assert (\n                metainfo_tool.inputSchema[\"properties\"][\"input_path\"].get(\"type\")\n                == \"string\"\n            ), \"input_path should be of type string\"\n\n\nclass TestMetainfoToolExecution:\n    \"\"\"Tests for the get_metainfo tool execution and results.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_metainfo_tool_execution(self, mcp_server: FastMCP, test_image_path):\n        \"\"\"Tests the get_metainfo tool execution and return value.\"\"\"\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"get_metainfo\", {\"input_path\": test_image_path}\n            )\n\n            # Check that the tool returned a result\n            # Parse the JSON string from the text attribute\n            metadata = result.data\n\n            # Verify the metadata contains expected fields\n            assert \"filename\" in metadata\n            assert \"size_bytes\" in metadata\n            assert \"dimensions\" in metadata\n            assert \"format\" in metadata\n            assert \"color_mode\" in metadata\n            assert \"created_at\" in metadata\n            assert \"modified_at\" in metadata\n\n            # Verify the dimensions are correct\n            assert metadata[\"dimensions\"][\"width\"] == 200\n            assert metadata[\"dimensions\"][\"height\"] == 200\n            assert metadata[\"dimensions\"][\"aspect_ratio\"] == 1.0\n\n            # Verify the format is correct\n            assert metadata[\"format\"] == \"PNG\"\n\n            # Verify the color mode is correct\n            assert metadata[\"color_mode\"] == \"RGB\"\n\n    @pytest.mark.asyncio\n    async def test_metainfo_nonexistent_file(self, mcp_server: FastMCP, tmp_path):\n        \"\"\"Tests the get_metainfo tool with a nonexistent file.\"\"\"\n        nonexistent_path = str(tmp_path / \"nonexistent.png\")\n\n        async with Client(mcp_server) as client:\n            with pytest.raises(Exception) as excinfo:\n                await client.call_tool(\"get_metainfo\", {\"input_path\": nonexistent_path})\n            \n        # The error message structure is different with FastMCP - it wraps the original error\n        # Just check that we got an error (any kind of exception is acceptable)\n        assert isinstance(excinfo.value, Exception)\n"
  },
  {
    "path": "tests/tools/test_ocr.py",
    "content": "\nimport cv2\nimport numpy as np\nimport pytest\nfrom fastmcp import Client, FastMCP\n\nfrom imagesorcery_mcp.server import mcp as image_sorcery_mcp_server\n\n# Add this line to filter out the PyTorch warnings\npytestmark = pytest.mark.filterwarnings(\"ignore:.*'pin_memory' argument is set as true but no accelerator is found.*:UserWarning\")\n\n\n@pytest.fixture\ndef mcp_server():\n    # Use the existing server instance\n    return image_sorcery_mcp_server\n\n\n@pytest.fixture\ndef test_image_path(tmp_path):\n    \"\"\"Create a test image with text for OCR.\"\"\"\n    img_path = tmp_path / \"test_ocr_image.png\"\n    \n    # Create a white image\n    img = np.ones((300, 600, 3), dtype=np.uint8) * 255\n    \n    # Add text to the image\n    font = cv2.FONT_HERSHEY_SIMPLEX\n    cv2.putText(img, \"Hello World\", (50, 50), font, 1, (0, 0, 0), 2)\n    cv2.putText(img, \"OCR Test\", (50, 150), font, 2, (0, 0, 0), 3)\n    cv2.putText(img, \"12345\", (50, 250), font, 1.5, (0, 0, 0), 2)\n    \n    # Save the image\n    cv2.imwrite(str(img_path), img)\n    return str(img_path)\n\n\nclass TestOcrToolDefinition:\n    \"\"\"Tests for the OCR tool definition and metadata.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_ocr_in_tools_list(self, mcp_server: FastMCP):\n        \"\"\"Tests that OCR tool is in the list of available tools.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            # Verify that tools list is not empty\n            assert tools, \"Tools list should not be empty\"\n\n            # Check if OCR is in the list of tools\n            tool_names = [tool.name for tool in tools]\n            assert \"ocr\" in tool_names, (\n                \"OCR tool should be in the list of available tools\"\n            )\n\n    @pytest.mark.asyncio\n    async def test_ocr_description(self, mcp_server: FastMCP):\n        \"\"\"Tests that OCR tool has the correct description.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            ocr_tool = next((tool for tool in tools if tool.name == \"ocr\"), None)\n\n            # Check description\n            assert ocr_tool.description, \"OCR tool should have a description\"\n            assert \"ocr\" in ocr_tool.description.lower() or \"optical character recognition\" in ocr_tool.description.lower(), (\n                \"Description should mention that it performs OCR on an image\"\n            )\n\n    @pytest.mark.asyncio\n    async def test_ocr_parameters(self, mcp_server: FastMCP):\n        \"\"\"Tests that OCR tool has the correct parameter structure.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            ocr_tool = next((tool for tool in tools if tool.name == \"ocr\"), None)\n\n            # Check input schema\n            assert hasattr(ocr_tool, \"inputSchema\"), (\n                \"OCR tool should have an inputSchema\"\n            )\n            assert \"properties\" in ocr_tool.inputSchema, (\n                \"inputSchema should have properties field\"\n            )\n\n            # Check required parameters\n            required_params = [\"input_path\"]\n            for param in required_params:\n                assert param in ocr_tool.inputSchema[\"properties\"], (\n                    f\"OCR tool should have a '{param}' property in its inputSchema\"\n                )\n\n            # Check optional parameters\n            optional_params = [\"language\"]\n            for param in optional_params:\n                assert param in ocr_tool.inputSchema[\"properties\"], (\n                    f\"OCR tool should have a '{param}' property in its inputSchema\"\n                )\n\n            # Check parameter types\n            assert (\n                ocr_tool.inputSchema[\"properties\"][\"input_path\"].get(\"type\")\n                == \"string\"\n            ), \"input_path should be of type string\"\n\n            # Check optional parameter (now has anyOf structure with null)\n            language_schema = ocr_tool.inputSchema[\"properties\"][\"language\"]\n            assert \"anyOf\" in language_schema, \"language should have anyOf structure for optional parameter\"\n            assert any(item.get(\"type\") == \"string\" for item in language_schema[\"anyOf\"]), \"language should allow string type\"\n            assert any(item.get(\"type\") == \"null\" for item in language_schema[\"anyOf\"]), \"language should allow null type\"\n\n\nclass TestOcrToolExecution:\n    \"\"\"Tests for the OCR tool execution and results.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_ocr_tool_execution(self, mcp_server: FastMCP, test_image_path):\n        \"\"\"Tests the OCR tool execution and return value.\"\"\"\n        try:\n            import easyocr  # noqa: F401\n        except ImportError:\n            pytest.skip(\"EasyOCR is not installed\")\n\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"ocr\",\n                {\n                    \"input_path\": test_image_path,\n                    \"language\": \"en\",\n                },\n            )\n\n            # Check that the tool returned a result\n            # Parse the result\n            ocr_result = result.structured_content\n\n            # Basic structure checks\n            assert \"image_path\" in ocr_result\n            assert \"text_segments\" in ocr_result\n            assert ocr_result[\"image_path\"] == test_image_path\n            assert isinstance(ocr_result[\"text_segments\"], list)\n\n            # Check that we have at least some text segments\n            assert len(ocr_result[\"text_segments\"]) > 0, (\n                \"No text segments detected in the test image\"\n            )\n\n            # Check the structure of a text segment\n            segment = ocr_result[\"text_segments\"][0]\n            assert \"text\" in segment, \"Text segment should have text content\"\n            assert \"confidence\" in segment, \"Text segment should have a confidence score\"\n            assert \"bbox\" in segment, \"Text segment should have a bounding box\"\n\n            # Check that the confidence is within expected range\n            assert 0 <= segment[\"confidence\"] <= 1, (\n                \"Confidence should be between 0 and 1\"\n            )\n\n            # Check that the bounding box has 4 coordinates\n            assert len(segment[\"bbox\"]) == 4, \"Bounding box should have 4 coordinates\"\n\n            # Check for expected text in the image\n            # We expect at least one of these texts to be detected\n            expected_texts = [\"Hello World\", \"OCR Test\", \"12345\"]\n            detected_texts = [segment[\"text\"] for segment in ocr_result[\"text_segments\"]]\n\n            # Check if any of our expected texts are detected (allowing for partial matches)\n            matches_found = False\n            for expected in expected_texts:\n                for detected in detected_texts:\n                    if expected.lower() in detected.lower() or detected.lower() in expected.lower():\n                        matches_found = True\n                        break\n                if matches_found:\n                    break\n\n            assert matches_found, (\n                f\"None of the expected texts {expected_texts} were detected. \"\n                f\"Detected texts: {detected_texts}\"\n            )\n"
  },
  {
    "path": "tests/tools/test_overlay.py",
    "content": "import os\n\nimport cv2\nimport numpy as np\nimport pytest\nfrom fastmcp import Client, FastMCP\n\nfrom imagesorcery_mcp.server import mcp as image_sorcery_mcp_server\n\n\n@pytest.fixture\ndef mcp_server():\n    # Use the existing server instance\n    return image_sorcery_mcp_server\n\n\n@pytest.fixture\ndef base_image_path(tmp_path):\n    \"\"\"Create a base test image.\"\"\"\n    img_path = tmp_path / \"base_image.png\"\n    # Create a blue image\n    img = np.full((300, 400, 3), (255, 0, 0), dtype=np.uint8)\n    cv2.imwrite(str(img_path), img)\n    return str(img_path)\n\n\n@pytest.fixture\ndef overlay_image_path_rgb(tmp_path):\n    \"\"\"Create an RGB overlay image (no alpha).\"\"\"\n    img_path = tmp_path / \"overlay_rgb.png\"\n    # Create a green square\n    img = np.full((100, 100, 3), (0, 255, 0), dtype=np.uint8)\n    cv2.imwrite(str(img_path), img)\n    return str(img_path)\n\n\n@pytest.fixture\ndef overlay_image_path_rgba(tmp_path):\n    \"\"\"Create an RGBA overlay image with transparency.\"\"\"\n    img_path = tmp_path / \"overlay_rgba.png\"\n    # Create a semi-transparent red circle on a transparent background\n    img = np.zeros((100, 100, 4), dtype=np.uint8)\n    cv2.circle(img, (50, 50), 40, (0, 0, 255, 128), -1)  # B,G,R,A (semi-transparent red)\n    cv2.imwrite(str(img_path), img)\n    return str(img_path)\n\n\nclass TestOverlayToolDefinition:\n    \"\"\"Tests for the overlay tool definition and metadata.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_overlay_in_tools_list(self, mcp_server: FastMCP):\n        \"\"\"Tests that overlay tool is in the list of available tools.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            assert tools, \"Tools list should not be empty\"\n            tool_names = [tool.name for tool in tools]\n            assert \"overlay\" in tool_names, \"overlay tool should be in the list of available tools\"\n\n    @pytest.mark.asyncio\n    async def test_overlay_parameters(self, mcp_server: FastMCP):\n        \"\"\"Tests that overlay tool has the correct parameter structure.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            overlay_tool = next((tool for tool in tools if tool.name == \"overlay\"), None)\n            assert overlay_tool is not None\n\n            props = overlay_tool.inputSchema[\"properties\"]\n            required = overlay_tool.inputSchema[\"required\"]\n\n            assert \"base_image_path\" in props and props[\"base_image_path\"][\"type\"] == \"string\"\n            assert \"overlay_image_path\" in props and props[\"overlay_image_path\"][\"type\"] == \"string\"\n            assert \"x\" in props and props[\"x\"][\"type\"] == \"integer\"\n            assert \"y\" in props and props[\"y\"][\"type\"] == \"integer\"\n            assert \"output_path\" in props\n\n            assert \"base_image_path\" in required\n            assert \"overlay_image_path\" in required\n            assert \"x\" in required\n            assert \"y\" in required\n\n\nclass TestOverlayToolExecution:\n    \"\"\"Tests for the overlay tool execution and results.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_overlay_rgb(self, mcp_server: FastMCP, base_image_path, overlay_image_path_rgb, tmp_path):\n        \"\"\"Tests overlaying an RGB image (no alpha).\"\"\"\n        output_path = str(tmp_path / \"output_rgb.png\")\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"overlay\",\n                {\n                    \"base_image_path\": base_image_path,\n                    \"overlay_image_path\": overlay_image_path_rgb,\n                    \"x\": 50,\n                    \"y\": 50,\n                    \"output_path\": output_path,\n                },\n            )\n\n            assert result.data == output_path\n            assert os.path.exists(output_path)\n\n            img = cv2.imread(output_path)\n            # Check a pixel inside the overlay area, it should be green\n            assert np.array_equal(img[100, 100], [0, 255, 0])\n            # Check a pixel outside the overlay area, it should be blue\n            assert np.array_equal(img[200, 200], [255, 0, 0])\n\n    @pytest.mark.asyncio\n    async def test_overlay_rgba(self, mcp_server: FastMCP, base_image_path, overlay_image_path_rgba, tmp_path):\n        \"\"\"Tests overlaying an RGBA image with transparency.\"\"\"\n        output_path = str(tmp_path / \"output_rgba.png\")\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"overlay\",\n                {\n                    \"base_image_path\": base_image_path,\n                    \"overlay_image_path\": overlay_image_path_rgba,\n                    \"x\": 50,\n                    \"y\": 50,\n                    \"output_path\": output_path,\n                },\n            )\n\n            assert result.data == output_path\n            assert os.path.exists(output_path)\n\n            img = cv2.imread(output_path)\n            # Check a pixel inside the overlay area, it should be purple\n            assert np.allclose(img[100, 100], [128, 0, 128], atol=2)\n            # Check a pixel outside the overlay area, it should be blue\n            assert np.array_equal(img[55, 55], [255, 0, 0])\n\n    @pytest.mark.asyncio\n    async def test_overlay_partial_offscreen(self, mcp_server: FastMCP, base_image_path, overlay_image_path_rgb, tmp_path):\n        \"\"\"Tests overlaying an image partially offscreen.\"\"\"\n        output_path = str(tmp_path / \"output_partial.png\")\n        async with Client(mcp_server) as client:\n            await client.call_tool(\n                \"overlay\",\n                {\n                    \"base_image_path\": base_image_path,\n                    \"overlay_image_path\": overlay_image_path_rgb,\n                    \"x\": 350,\n                    \"y\": 250,\n                    \"output_path\": output_path,\n                },\n            )\n\n            assert os.path.exists(output_path)\n            img = cv2.imread(output_path)\n            # Check a pixel inside the overlay area, it should be green\n            assert np.array_equal(img[299, 399], [0, 255, 0])\n            # Check a pixel outside the overlay area, it should be blue\n            assert np.array_equal(img[299, 349], [255, 0, 0])\n\n    @pytest.mark.asyncio\n    async def test_overlay_default_output_path(self, mcp_server: FastMCP, base_image_path, overlay_image_path_rgb):\n        \"\"\"Tests the overlay tool with a default output path.\"\"\"\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"overlay\",\n                {\n                    \"base_image_path\": base_image_path,\n                    \"overlay_image_path\": overlay_image_path_rgb,\n                    \"x\": 0,\n                    \"y\": 0,\n                },\n            )\n            expected_output = base_image_path.replace(\".png\", \"_overlaid.png\")\n            assert result.data == expected_output\n            assert os.path.exists(expected_output)\n"
  },
  {
    "path": "tests/tools/test_resize.py",
    "content": "import os\n\nimport cv2\nimport numpy as np\nimport pytest\nfrom fastmcp import Client, FastMCP\n\nfrom imagesorcery_mcp.server import mcp as image_sorcery_mcp_server\n\n\n@pytest.fixture\ndef mcp_server():\n    # Use the existing server instance\n    return image_sorcery_mcp_server\n\n\n@pytest.fixture\ndef test_image_path(tmp_path):\n    \"\"\"Create a test image for resizing.\"\"\"\n    img_path = tmp_path / \"test_image.png\"\n    # Create a white image\n    img = np.ones((200, 300, 3), dtype=np.uint8) * 255\n\n    # Draw some colored areas to verify resizing\n    # Red square (50,50) to (100,100)\n    img[50:100, 50:100] = [0, 0, 255]  # OpenCV uses BGR\n\n    # Blue square (100,100) to (150,150)\n    img[100:150, 100:150] = [255, 0, 0]  # OpenCV uses BGR\n\n    # Draw a green circle in the center with thickness 5px and diameter 100px\n    center = (150, 100)  # x, y coordinates (center of the image)\n    radius = 50  # 100px diameter\n    color = (0, 255, 0)  # Green in BGR\n    thickness = 5\n    cv2.circle(img, center, radius, color, thickness)\n\n    # Add text \"TEST\" in the center\n    font = cv2.FONT_HERSHEY_SIMPLEX\n    font_scale = 1\n    text_color = (0, 0, 0)  # Black in BGR\n    text_thickness = 2\n    text = \"TEST\"\n\n    # Get text size to center it properly\n    text_size = cv2.getTextSize(text, font, font_scale, text_thickness)[0]\n    text_x = int(center[0] - text_size[0] / 2)\n    text_y = int(center[1] + text_size[1] / 2)\n\n    cv2.putText(\n        img, text, (text_x, text_y), font, font_scale, text_color, text_thickness\n    )\n\n    cv2.imwrite(str(img_path), img)\n    return str(img_path)\n\n\nclass TestResizeToolDefinition:\n    \"\"\"Tests for the resize tool definition and metadata.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_resize_in_tools_list(self, mcp_server: FastMCP):\n        \"\"\"Tests that resize tool is in the list of available tools.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            # Verify that tools list is not empty\n            assert tools, \"Tools list should not be empty\"\n\n            # Check if resize is in the list of tools\n            tool_names = [tool.name for tool in tools]\n            assert \"resize\" in tool_names, (\n                \"resize tool should be in the list of available tools\"\n            )\n\n    @pytest.mark.asyncio\n    async def test_resize_description(self, mcp_server: FastMCP):\n        \"\"\"Tests that resize tool has the correct description.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            resize_tool = next((tool for tool in tools if tool.name == \"resize\"), None)\n\n            # Check description\n            assert resize_tool.description, \"resize tool should have a description\"\n            assert \"resize\" in resize_tool.description.lower(), (\n                \"Description should mention that it resizes an image\"\n            )\n\n    @pytest.mark.asyncio\n    async def test_resize_parameters(self, mcp_server: FastMCP):\n        \"\"\"Tests that resize tool has the correct parameter structure.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            resize_tool = next((tool for tool in tools if tool.name == \"resize\"), None)\n\n            # Check input schema\n            assert hasattr(resize_tool, \"inputSchema\"), (\n                \"resize tool should have an inputSchema\"\n            )\n            assert \"properties\" in resize_tool.inputSchema, (\n                \"inputSchema should have properties field\"\n            )\n\n            # Check required parameters\n            assert \"input_path\" in resize_tool.inputSchema[\"properties\"], (\n                \"resize tool should have an 'input_path' property in its inputSchema\"\n            )\n\n            # Check optional parameters\n            optional_params = [\n                \"width\",\n                \"height\",\n                \"scale_factor\",\n                \"interpolation\",\n                \"output_path\",\n            ]\n            for param in optional_params:\n                assert param in resize_tool.inputSchema[\"properties\"], (\n                    f\"resize tool should have a '{param}' property in its inputSchema\"\n                )\n\n                # Check parameter types - accounting for optional parameters\n                # that use anyOf structure\n                assert (\n                    resize_tool.inputSchema[\"properties\"][\"input_path\"].get(\"type\")\n                    == \"string\"\n                ), \"input_path should be of type string\"\n\n                # For optional integer parameters, check if they have the correct type\n                # in anyOf structure\n                for param in [\"width\", \"height\"]:\n                    param_schema = resize_tool.inputSchema[\"properties\"][param]\n                    if \"anyOf\" in param_schema:\n                        # Check if one of the anyOf options is integer\n                        has_integer_type = any(\n                            option.get(\"type\") == \"integer\"\n                            for option in param_schema[\"anyOf\"]\n                        )\n                        assert has_integer_type, f\"{param} should allow integer type\"\n                    else:\n                        assert param_schema.get(\"type\") == \"integer\", (\n                            f\"{param} should be of type integer\"\n                        )\n\n                # For scale_factor (float parameter)\n                scale_factor_schema = resize_tool.inputSchema[\"properties\"][\n                    \"scale_factor\"\n                ]\n                if \"anyOf\" in scale_factor_schema:\n                    # Check if one of the anyOf options is number\n                    has_number_type = any(\n                        option.get(\"type\") == \"number\"\n                        for option in scale_factor_schema[\"anyOf\"]\n                    )\n                    assert has_number_type, \"scale_factor should allow number type\"\n                else:\n                    assert scale_factor_schema.get(\"type\") == \"number\", (\n                        \"scale_factor should be of type number\"\n                    )\n\n                # For string parameters\n                for param in [\"interpolation\", \"output_path\"]:\n                    param_schema = resize_tool.inputSchema[\"properties\"][param]\n                    if \"anyOf\" in param_schema:\n                        # Check if one of the anyOf options is string\n                        has_string_type = any(\n                            option.get(\"type\") == \"string\"\n                            for option in param_schema[\"anyOf\"]\n                        )\n                        assert has_string_type, f\"{param} should allow string type\"\n                    else:\n                        assert param_schema.get(\"type\") == \"string\", (\n                            f\"{param} should be of type string\"\n                        )\n\n\nclass TestResizeToolExecution:\n    \"\"\"Tests for the resize tool execution and results.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_resize_with_dimensions_smaller(\n        self, mcp_server: FastMCP, test_image_path, tmp_path\n    ):\n        \"\"\"Tests the resize tool execution with specific dimensions (smaller).\"\"\"\n        output_path = str(tmp_path / \"output_dimensions_smaller.png\")\n\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"resize\",\n                {\n                    \"input_path\": test_image_path,\n                    \"width\": 150,\n                    \"height\": 100,\n                    \"output_path\": output_path,\n                },\n            )\n\n            # Check that the tool returned a result\n            assert result.data == output_path\n\n            # Verify the file exists\n            assert os.path.exists(output_path)\n\n            # Verify the resized image dimensions\n            img = cv2.imread(output_path)\n            assert img.shape[:2] == (100, 150)  # height, width\n\n    @pytest.mark.asyncio\n    async def test_resize_with_dimensions_larger(\n        self, mcp_server: FastMCP, test_image_path, tmp_path\n    ):\n        \"\"\"Tests the resize tool execution with specific dimensions (larger).\"\"\"\n        output_path = str(tmp_path / \"output_dimensions_larger.png\")\n\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"resize\",\n                {\n                    \"input_path\": test_image_path,\n                    \"width\": 600,\n                    \"height\": 400,\n                    \"output_path\": output_path,\n                },\n            )\n\n            # Check that the tool returned a result\n            assert result.data == output_path\n\n            # Verify the file exists\n            assert os.path.exists(output_path)\n\n            # Verify the resized image dimensions\n            img = cv2.imread(output_path)\n            assert img.shape[:2] == (400, 600)  # height, width\n\n    @pytest.mark.asyncio\n    async def test_resize_with_width_only_smaller(\n        self, mcp_server: FastMCP, test_image_path, tmp_path\n    ):\n        \"\"\"Tests the resize tool execution with only width specified\n        (smaller, preserving aspect ratio).\"\"\"\n        output_path = str(tmp_path / \"output_width_only_smaller.png\")\n\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"resize\",\n                {\n                    \"input_path\": test_image_path,\n                    \"width\": 150,\n                    \"output_path\": output_path,\n                },\n            )\n\n            # Check that the tool returned a result\n            assert result.data == output_path\n\n            # Verify the file exists\n            assert os.path.exists(output_path)\n\n            # Verify the resized image dimensions\n            img = cv2.imread(output_path)\n            assert img.shape[1] == 150  # width\n            # Height should be proportional (original: 200x300, new width: 150)\n            # So new height should be 200 * (150/300) = 100\n            assert img.shape[0] == 100  # height\n\n    @pytest.mark.asyncio\n    async def test_resize_with_width_only_larger(\n        self, mcp_server: FastMCP, test_image_path, tmp_path\n    ):\n        \"\"\"Tests the resize tool execution with only width specified\n        (larger, preserving aspect ratio).\"\"\"\n        output_path = str(tmp_path / \"output_width_only_larger.png\")\n\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"resize\",\n                {\n                    \"input_path\": test_image_path,\n                    \"width\": 600,\n                    \"output_path\": output_path,\n                },\n            )\n\n            # Check that the tool returned a result\n            assert result.data == output_path\n\n            # Verify the file exists\n            assert os.path.exists(output_path)\n\n            # Verify the resized image dimensions\n            img = cv2.imread(output_path)\n            assert img.shape[1] == 600  # width\n            # Height should be proportional (original: 200x300, new width: 600)\n            # So new height should be 200 * (600/300) = 400\n            assert img.shape[0] == 400  # height\n\n    @pytest.mark.asyncio\n    async def test_resize_with_height_only_smaller(\n        self, mcp_server: FastMCP, test_image_path, tmp_path\n    ):\n        \"\"\"Tests the resize tool execution with only height specified\n        (smaller, preserving aspect ratio).\"\"\"\n        output_path = str(tmp_path / \"output_height_only_smaller.png\")\n\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"resize\",\n                {\n                    \"input_path\": test_image_path,\n                    \"height\": 100,\n                    \"output_path\": output_path,\n                },\n            )\n\n            # Check that the tool returned a result\n            assert result.data == output_path\n\n            # Verify the file exists\n            assert os.path.exists(output_path)\n\n            # Verify the resized image dimensions\n            img = cv2.imread(output_path)\n            assert img.shape[0] == 100  # height\n            # Width should be proportional (original: 200x300, new height: 100)\n            # So new width should be 300 * (100/200) = 150\n            assert img.shape[1] == 150  # width\n\n    @pytest.mark.asyncio\n    async def test_resize_with_height_only_larger(\n        self, mcp_server: FastMCP, test_image_path, tmp_path\n    ):\n        \"\"\"Tests the resize tool execution with only height specified\n        (larger, preserving aspect ratio).\"\"\"\n        output_path = str(tmp_path / \"output_height_only_larger.png\")\n\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"resize\",\n                {\n                    \"input_path\": test_image_path,\n                    \"height\": 400,\n                    \"output_path\": output_path,\n                },\n            )\n\n            # Check that the tool returned a result\n            assert result.data == output_path\n\n            # Verify the file exists\n            assert os.path.exists(output_path)\n\n            # Verify the resized image dimensions\n            img = cv2.imread(output_path)\n            assert img.shape[0] == 400  # height\n            # Width should be proportional (original: 200x300, new height: 400)\n            # So new width should be 300 * (400/200) = 600\n            assert img.shape[1] == 600  # width\n\n    @pytest.mark.asyncio\n    async def test_resize_with_scale_factor_smaller(\n        self, mcp_server: FastMCP, test_image_path, tmp_path\n    ):\n        \"\"\"Tests the resize tool execution with scale factor (smaller).\"\"\"\n        output_path = str(tmp_path / \"output_scale_smaller.png\")\n\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"resize\",\n                {\n                    \"input_path\": test_image_path,\n                    \"scale_factor\": 0.5,\n                    \"output_path\": output_path,\n                },\n            )\n\n            # Check that the tool returned a result\n            assert result.data == output_path\n\n            # Verify the file exists\n            assert os.path.exists(output_path)\n\n            # Verify the resized image dimensions\n            img = cv2.imread(output_path)\n            # Original: 200x300, scale: 0.5, so new dimensions should be 100x150\n            assert img.shape[:2] == (100, 150)  # height, width\n\n    @pytest.mark.asyncio\n    async def test_resize_with_scale_factor_larger(\n        self, mcp_server: FastMCP, test_image_path, tmp_path\n    ):\n        \"\"\"Tests the resize tool execution with scale factor (larger).\"\"\"\n        output_path = str(tmp_path / \"output_scale_larger.png\")\n\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"resize\",\n                {\n                    \"input_path\": test_image_path,\n                    \"scale_factor\": 2.0,\n                    \"output_path\": output_path,\n                },\n            )\n\n            # Check that the tool returned a result\n            assert result.data == output_path\n\n            # Verify the file exists\n            assert os.path.exists(output_path)\n\n            # Verify the resized image dimensions\n            img = cv2.imread(output_path)\n            # Original: 200x300, scale: 2.0, so new dimensions should be 400x600\n            assert img.shape[:2] == (400, 600)  # height, width\n\n    @pytest.mark.asyncio\n    async def test_resize_default_output_path(\n        self, mcp_server: FastMCP, test_image_path\n    ):\n        \"\"\"Tests the resize tool with default output path.\"\"\"\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"resize\", {\"input_path\": test_image_path, \"width\": 150, \"height\": 100}\n            )\n\n            # Check that the tool returned a result\n            expected_output = test_image_path.replace(\".png\", \"_resized.png\")\n            assert result.data == expected_output\n\n            # Verify the file exists\n            assert os.path.exists(expected_output)\n\n            # Verify the resized image dimensions\n            img = cv2.imread(expected_output)\n            assert img.shape[:2] == (100, 150)  # height, width\n\n    @pytest.mark.asyncio\n    async def test_resize_with_interpolation(\n        self, mcp_server: FastMCP, test_image_path, tmp_path\n    ):\n        \"\"\"Tests the resize tool with different interpolation methods.\"\"\"\n        interpolation_methods = [\"nearest\", \"linear\", \"area\", \"cubic\", \"lanczos\"]\n\n        for method in interpolation_methods:\n            # Test downscaling\n            output_path_smaller = str(tmp_path / f\"output_{method}_smaller.png\")\n\n            async with Client(mcp_server) as client:\n                result = await client.call_tool(\n                    \"resize\",\n                    {\n                        \"input_path\": test_image_path,\n                        \"width\": 150,\n                        \"height\": 100,\n                        \"interpolation\": method,\n                        \"output_path\": output_path_smaller,\n                    },\n                )\n\n                # Check that the tool returned a result\n                assert result.data == output_path_smaller\n\n                # Verify the file exists\n                assert os.path.exists(output_path_smaller)\n\n                # Verify the resized image dimensions\n                img = cv2.imread(output_path_smaller)\n                assert img.shape[:2] == (100, 150)  # height, width\n\n            # Test upscaling\n            output_path_larger = str(tmp_path / f\"output_{method}_larger.png\")\n\n            async with Client(mcp_server) as client:\n                result = await client.call_tool(\n                    \"resize\",\n                    {\n                        \"input_path\": test_image_path,\n                        \"width\": 600,\n                        \"height\": 400,\n                        \"interpolation\": method,\n                        \"output_path\": output_path_larger,\n                    },\n                )\n\n                # Check that the tool returned a result\n                assert result.data == output_path_larger\n\n                # Verify the file exists\n                assert os.path.exists(output_path_larger)\n\n                # Verify the resized image dimensions\n                img = cv2.imread(output_path_larger)\n                assert img.shape[:2] == (400, 600)  # height, width\n"
  },
  {
    "path": "tests/tools/test_rotate.py",
    "content": "import os\n\nimport cv2\nimport numpy as np\nimport pytest\nfrom fastmcp import Client, FastMCP\n\nfrom imagesorcery_mcp.server import mcp as image_sorcery_mcp_server\n\n\n@pytest.fixture\ndef mcp_server():\n    # Use the existing server instance\n    return image_sorcery_mcp_server\n\n\n@pytest.fixture\ndef test_image_path(tmp_path):\n    \"\"\"Create a test image for rotation.\"\"\"\n    img_path = tmp_path / \"test_image.png\"\n    # Create a white image\n    img = np.ones((100, 200, 3), dtype=np.uint8) * 255\n\n    # Draw a red rectangle in the top-left corner to verify rotation\n    # Red rectangle from (10,10) to (40,40)\n    img[10:40, 10:40] = [0, 0, 255]  # OpenCV uses BGR\n\n    cv2.imwrite(str(img_path), img)\n    return str(img_path)\n\n\nclass TestRotateToolDefinition:\n    \"\"\"Tests for the rotate tool definition and metadata.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_rotate_in_tools_list(self, mcp_server: FastMCP):\n        \"\"\"Tests that rotate tool is in the list of available tools.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            # Verify that tools list is not empty\n            assert tools, \"Tools list should not be empty\"\n\n            # Check if rotate is in the list of tools\n            tool_names = [tool.name for tool in tools]\n            assert \"rotate\" in tool_names, (\n                \"rotate tool should be in the list of available tools\"\n            )\n\n    @pytest.mark.asyncio\n    async def test_rotate_description(self, mcp_server: FastMCP):\n        \"\"\"Tests that rotate tool has the correct description.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            rotate_tool = next((tool for tool in tools if tool.name == \"rotate\"), None)\n\n            # Check description\n            assert rotate_tool.description, \"rotate tool should have a description\"\n            assert \"rotate\" in rotate_tool.description.lower(), (\n                \"Description should mention that it rotates an image\"\n            )\n\n    @pytest.mark.asyncio\n    async def test_rotate_parameters(self, mcp_server: FastMCP):\n        \"\"\"Tests that rotate tool has the correct parameter structure.\"\"\"\n        async with Client(mcp_server) as client:\n            tools = await client.list_tools()\n            rotate_tool = next((tool for tool in tools if tool.name == \"rotate\"), None)\n\n            # Check input schema\n            assert hasattr(rotate_tool, \"inputSchema\"), (\n                \"rotate tool should have an inputSchema\"\n            )\n            assert \"properties\" in rotate_tool.inputSchema, (\n                \"inputSchema should have properties field\"\n            )\n\n            # Check required parameters\n            required_params = [\"input_path\", \"angle\"]\n            for param in required_params:\n                assert param in rotate_tool.inputSchema[\"properties\"], (\n                    f\"rotate tool should have a '{param}' property in its inputSchema\"\n                )\n\n            # Check optional parameters\n            assert \"output_path\" in rotate_tool.inputSchema[\"properties\"], (\n                \"rotate tool should have an 'output_path' property in its inputSchema\"\n            )\n\n            # Check parameter types\n            assert (\n                rotate_tool.inputSchema[\"properties\"][\"input_path\"].get(\"type\")\n                == \"string\"\n            ), \"input_path should be of type string\"\n            assert rotate_tool.inputSchema[\"properties\"][\"angle\"].get(\"type\") in [\n                \"number\",\n                \"integer\",\n                \"float\",\n            ], \"angle should be a numeric type\"\n            assert (\n                rotate_tool.inputSchema[\"properties\"][\"output_path\"].get(\"type\")\n                == \"string\"\n            ), \"output_path should be of type string\"\n\n\nclass TestRotateToolExecution:\n    \"\"\"Tests for the rotate tool execution and results.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_rotate_tool_execution(\n        self, mcp_server: FastMCP, test_image_path, tmp_path\n    ):\n        \"\"\"Tests the rotate tool execution and return value.\"\"\"\n        output_path = str(tmp_path / \"output.png\")\n\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"rotate\",\n                {\n                    \"input_path\": test_image_path,\n                    \"angle\": 90,\n                    \"output_path\": output_path,\n                },\n            )\n\n            # Check that the tool returned a result\n            assert result.data == output_path\n\n            # Verify the file exists\n            assert os.path.exists(output_path)\n\n            # Verify the rotated image dimensions\n            original_img = cv2.imread(test_image_path)\n            rotated_img = cv2.imread(output_path)\n\n            # For a 90-degree rotation, width and height should be approximately swapped\n            # Due to the rotate_bound function, dimensions might be slightly larger\n            # to fit the entire rotated image\n            # We check that the original width is close to the rotated height\n            # and vice versa\n            original_height, original_width = original_img.shape[:2]\n            rotated_height, rotated_width = rotated_img.shape[:2]\n\n            # Allow for a small margin of error due to padding in rotate_bound\n            margin = 5\n            assert abs(original_width - rotated_height) <= margin, (\n                \"Original width should approximately match rotated height \"\n                \"for 90-degree rotation\"\n            )\n            assert abs(original_height - rotated_width) <= margin, (\n                \"Original height should approximately match rotated width \"\n                \"for 90-degree rotation\"\n            )\n\n            # Verify the rotation by checking the position of the red rectangle\n            # In the original image, the red rectangle is in the top-left corner\n            # (10,10) to (40,40)\n            # After 90-degree counterclockwise rotation, it should be in the\n            # top-right area\n\n            # Check if the top-right area has red pixels (BGR format)\n            # For 90-degree counterclockwise rotation, the red rectangle should move\n            # from top-left to top-right\n            # We need to check the appropriate coordinates in the rotated image\n\n            # The exact coordinates depend on how rotate_bound handles the rotation\n            # and padding\n            # For a 90-degree counterclockwise rotation of a 100x200 image with a\n            # red rectangle at (10,10)-(40,40),\n            # the red rectangle should be approximately in the top-right area\n\n            # Check if there are red pixels in the expected area after rotation\n            # For 90-degree counterclockwise rotation, the top-left (10,10) would move\n            # to approximately (10, rotated_width-40)\n            has_red_pixels = False\n            for y in range(10, 40):\n                for x in range(rotated_width - 40, rotated_width - 10):\n                    if x >= 0 and x < rotated_width and y >= 0 and y < rotated_height:\n                        # Check if pixel is red (BGR format: [0,0,255])\n                        pixel = rotated_img[y, x]\n                        if (\n                            pixel[0] < 50 and pixel[1] < 50 and pixel[2] > 200\n                        ):  # Allow for some color variation\n                            has_red_pixels = True\n                            break\n                if has_red_pixels:\n                    break\n\n            assert has_red_pixels, (\n                \"Red rectangle should be in the top-right area after \"\n                \"90-degree counterclockwise rotation\"\n            )\n\n    @pytest.mark.asyncio\n    async def test_rotate_clockwise(\n        self, mcp_server: FastMCP, test_image_path, tmp_path\n    ):\n        \"\"\"Tests the rotate tool with clockwise rotation (-90 degrees).\"\"\"\n        output_path = str(tmp_path / \"output_clockwise.png\")\n\n        async with Client(mcp_server) as client:\n            await client.call_tool(\n                \"rotate\",\n                {\n                    \"input_path\": test_image_path,\n                    \"angle\": -90,  # Negative angle for clockwise rotation\n                    \"output_path\": output_path,\n                },\n            )\n\n            # Verify the file exists\n            assert os.path.exists(output_path)\n\n            # Load the rotated image\n            rotated_img = cv2.imread(output_path)\n            rotated_height, rotated_width = rotated_img.shape[:2]\n\n            # For -90-degree (clockwise) rotation, the red rectangle should move\n            # from top-left to bottom-left\n            # Check if there are red pixels in the expected area after rotation\n            has_red_pixels = False\n            for y in range(rotated_height - 40, rotated_height - 10):\n                for x in range(10, 40):\n                    if x >= 0 and x < rotated_width and y >= 0 and y < rotated_height:\n                        # Check if pixel is red (BGR format: [0,0,255])\n                        pixel = rotated_img[y, x]\n                        if (\n                            pixel[0] < 50 and pixel[1] < 50 and pixel[2] > 200\n                        ):  # Allow for some color variation\n                            has_red_pixels = True\n                            break\n                if has_red_pixels:\n                    break\n\n            assert has_red_pixels, (\n                \"Red rectangle should be in the bottom-left area after \"\n                \"90-degree clockwise rotation\"\n            )\n\n    @pytest.mark.asyncio\n    async def test_rotate_default_output_path(\n        self, mcp_server: FastMCP, test_image_path\n    ):\n        \"\"\"Tests the rotate tool with default output path.\"\"\"\n        async with Client(mcp_server) as client:\n            result = await client.call_tool(\n                \"rotate\", {\"input_path\": test_image_path, \"angle\": 45}\n            )\n\n            # Check that the tool returned a result\n            expected_output = test_image_path.replace(\".png\", \"_rotated.png\")\n            assert result.data == expected_output\n\n            # Verify the file exists\n            assert os.path.exists(expected_output)\n            assert os.path.exists(expected_output)\n"
  }
]