Initial commit: DreamTail MCP client
🎨 MCP integration for DreamTail (SDXL on Jetson Orin) - dreamtail_generate: Create images with prompts - dreamtail_get_info: Get last generated image path - Inline display support for Claude Desktop - Configurable JPEG quality and download directory Built with love for the hardware dragon 🦊
This commit is contained in:
394
README.md
Executable file
394
README.md
Executable file
@@ -0,0 +1,394 @@
|
||||
# 🎨 DreamTail MCP Server
|
||||
|
||||
Model Context Protocol (MCP) server for integrating [DreamTail](../dreamtail) SDXL image generation with Claude Desktop.
|
||||
|
||||
## Overview
|
||||
|
||||
This MCP server exposes DreamTail's Stable Diffusion XL image generation capabilities to Claude Desktop through a single, simple tool: `dreamtail_generate()`.
|
||||
|
||||
**Features:**
|
||||
- 🎨 Generate high-quality 1024x1024 images using SDXL
|
||||
- 🖼️ **Inline image display** in Claude Desktop (new!)
|
||||
- ⏱️ Automatic job submission and polling (no manual checking)
|
||||
- 💾 Automatic download to local folder (configurable)
|
||||
- 🗜️ Smart format conversion with adaptive compression for 1MB limit
|
||||
- 🔄 Built-in progress tracking and timeout handling
|
||||
- 💬 Natural language prompts via Claude
|
||||
- ⚡ Powered by Jetson AGX Orin GPU
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **DreamTail service** running on bigorin:8765
|
||||
2. **Python 3.8+** installed
|
||||
3. **Claude Desktop** installed
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Install Dependencies
|
||||
|
||||
```bash
|
||||
cd /home/alex/Projects/dreamtail-mcp
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Or using `uv` (recommended):
|
||||
|
||||
```bash
|
||||
uv pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 2. Make Script Executable
|
||||
|
||||
```bash
|
||||
chmod +x dreamtail_mcp.py
|
||||
```
|
||||
|
||||
### 3. Test the Server Locally
|
||||
|
||||
```bash
|
||||
# Test that the server starts without errors
|
||||
python3 dreamtail_mcp.py
|
||||
# Press Ctrl+C to stop
|
||||
```
|
||||
|
||||
## Claude Desktop Configuration
|
||||
|
||||
### Step 1: Locate Configuration File
|
||||
|
||||
**macOS:**
|
||||
```bash
|
||||
~/Library/Application Support/Claude/claude_desktop_config.json
|
||||
```
|
||||
|
||||
**Or use Claude Desktop:**
|
||||
- Open Claude Desktop
|
||||
- Go to: Settings → Developer → "Edit Config"
|
||||
|
||||
### Step 2: Add DreamTail MCP Server
|
||||
|
||||
Add this configuration to your `claude_desktop_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"dreamtail": {
|
||||
"command": "python3",
|
||||
"args": [
|
||||
"/home/alex/Projects/dreamtail-mcp/dreamtail_mcp.py"
|
||||
],
|
||||
"env": {
|
||||
"DREAMTAIL_BASE_URL": "http://192.168.50.30:8765",
|
||||
"DREAMTAIL_DOWNLOAD_DIR": "/Users/yourname/Pictures/dreamtail",
|
||||
"DREAMTAIL_FORMAT": "jpeg",
|
||||
"DREAMTAIL_JPEG_QUALITY": "95",
|
||||
"DREAMTAIL_INLINE_DISPLAY": "true",
|
||||
"DREAMTAIL_INLINE_QUALITY": "85"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Important notes:**
|
||||
- Use the **absolute path** to `dreamtail_mcp.py`
|
||||
- Use the **IP address** for `DREAMTAIL_BASE_URL` instead of hostname (e.g., `192.168.50.30:8765` instead of `bigorin:8765`)
|
||||
- Set `DREAMTAIL_DOWNLOAD_DIR` to your preferred download location (defaults to `~/dreamtail_images`)
|
||||
- `DREAMTAIL_FORMAT` options: `"jpeg"` (default), `"png"`, or `"both"` - for saved files
|
||||
- `DREAMTAIL_JPEG_QUALITY`: 1-100 (default: 95) - quality for saved files
|
||||
- `DREAMTAIL_INLINE_DISPLAY`: `"true"` (default) or `"false"` - enable inline image display in Claude Desktop
|
||||
- `DREAMTAIL_INLINE_QUALITY`: 1-100 (default: 85) - JPEG quality for inline display (lower = smaller file for 1MB limit)
|
||||
- If you have other MCP servers configured, add the "dreamtail" entry alongside them
|
||||
|
||||
### Step 3: Restart Claude Desktop
|
||||
|
||||
**You MUST completely quit and restart Claude Desktop** for the changes to take effect:
|
||||
|
||||
**macOS:**
|
||||
```bash
|
||||
# Quit Claude Desktop completely
|
||||
# Then reopen from Applications
|
||||
```
|
||||
|
||||
### Step 4: Verify Installation
|
||||
|
||||
1. Open Claude Desktop
|
||||
2. Look for the 🔨 (hammer) icon in the bottom-right corner of the chat input
|
||||
3. Click it to see available tools
|
||||
4. You should see "DreamTail Image Generator" with the `dreamtail_generate` tool
|
||||
|
||||
## Usage
|
||||
|
||||
Once configured, you can ask Claude to generate images naturally:
|
||||
|
||||
### Example Prompts
|
||||
|
||||
**Basic generation:**
|
||||
```
|
||||
Generate an image of a serene mountain landscape at sunset
|
||||
```
|
||||
|
||||
**With negative prompt:**
|
||||
```
|
||||
Create an image of a cat wearing a wizard hat, but avoid any blurry or
|
||||
low-quality elements
|
||||
```
|
||||
|
||||
**Custom dimensions:**
|
||||
```
|
||||
Generate a 512x512 image of a futuristic cityscape
|
||||
```
|
||||
|
||||
**More inference steps for quality:**
|
||||
```
|
||||
Create a highly detailed portrait of a robot, using 50 inference steps
|
||||
```
|
||||
|
||||
### What Claude Will Do
|
||||
|
||||
1. Parse your natural language request
|
||||
2. Call `dreamtail_generate()` with appropriate parameters
|
||||
3. Wait ~45-60 seconds while the image generates
|
||||
4. **Display the image inline** in the chat (when enabled)
|
||||
5. Automatically save full-resolution version to your local folder
|
||||
|
||||
### Response Format
|
||||
|
||||
**With inline display enabled (default):**
|
||||
|
||||
Claude will show the generated image directly in the conversation! No need to open files or URLs - the image appears immediately for analysis and discussion.
|
||||
|
||||
The image displayed inline is:
|
||||
- Compressed JPEG at quality 85 (or lower if needed to fit 1MB limit)
|
||||
- May be slightly resized if the original is too large
|
||||
- Optimized for fast loading while maintaining visual quality
|
||||
|
||||
Full-resolution files are saved to disk:
|
||||
- Location: `~/dreamtail_images/{job_id}.jpg` (and `.png` if configured)
|
||||
- Quality: Your configured JPEG quality (default 95) or original PNG
|
||||
- Use these for high-quality archival or further editing
|
||||
|
||||
**With inline display disabled:**
|
||||
|
||||
Claude will provide a text message:
|
||||
```
|
||||
Image generated successfully!
|
||||
Saved to: /Users/yourname/Pictures/dreamtail/{job_id}.jpg
|
||||
```
|
||||
|
||||
You can then open the file directly from your local filesystem.
|
||||
|
||||
## Tool Reference
|
||||
|
||||
### `dreamtail_generate()`
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `prompt` | string | *required* | Text description of image to generate |
|
||||
| `negative_prompt` | string | None | What to avoid in the image (optional) |
|
||||
| `width` | integer | 1024 | Image width (must be multiple of 8) |
|
||||
| `height` | integer | 1024 | Image height (must be multiple of 8) |
|
||||
| `num_inference_steps` | integer | 30 | Denoising steps (20-50, higher = better quality but slower) |
|
||||
| `client_id` | string | "claude" | Client identifier |
|
||||
|
||||
**Returns:**
|
||||
|
||||
```python
|
||||
{
|
||||
"status": "completed", # or "failed", "timeout", "error"
|
||||
"job_id": "uuid",
|
||||
"image_url": "http://192.168.50.30:8765/result/{job_id}",
|
||||
"local_paths": ["/path/to/download/dir/{job_id}.jpg"], # List of saved files
|
||||
"progress": 100,
|
||||
"message": "Image generated successfully! Saved to: /path/to/download/dir/{job_id}.jpg"
|
||||
}
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- `local_paths` contains 1 file (JPEG or PNG) or 2 files (when `DREAMTAIL_FORMAT="both"`)
|
||||
- JPEG files are optimized and ~10x smaller than PNG with negligible quality loss at 95%
|
||||
|
||||
**Generation Time:**
|
||||
- Typical: ~45-60 seconds for 1024x1024 @ 30 steps
|
||||
- Fast: ~30-40 seconds for 20 steps or 512x512
|
||||
- High quality: ~60-90 seconds for 50 steps
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Tool Not Appearing in Claude Desktop
|
||||
|
||||
1. **Check config path**: Ensure you're editing the correct config file
|
||||
2. **Verify absolute path**: Must be absolute, not relative (e.g., `/home/alex/...` not `~/...`)
|
||||
3. **Restart Claude**: Completely quit and reopen Claude Desktop
|
||||
4. **Check logs**: Look for errors in Claude Desktop's logs
|
||||
|
||||
### "Failed to connect to DreamTail"
|
||||
|
||||
1. **Check DreamTail is running**:
|
||||
```bash
|
||||
curl http://bigorin:8765/health
|
||||
```
|
||||
|
||||
2. **Verify network access**: Ensure your machine can reach bigorin:8765
|
||||
|
||||
3. **Update config**: Check `DREAMTAIL_BASE_URL` in Claude config
|
||||
|
||||
### "Generation exceeded timeout"
|
||||
|
||||
- Normal generation takes 45-60 seconds
|
||||
- If you get timeouts, DreamTail might be overloaded or having issues
|
||||
- Check DreamTail logs: `ssh bigorin "sudo docker logs dreamtail"`
|
||||
|
||||
### Import Errors
|
||||
|
||||
```bash
|
||||
# Reinstall dependencies
|
||||
cd /home/alex/Projects/dreamtail-mcp
|
||||
pip install --upgrade -r requirements.txt
|
||||
```
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Inline Display Options
|
||||
|
||||
Control how images are displayed in Claude Desktop:
|
||||
|
||||
```json
|
||||
"env": {
|
||||
"DREAMTAIL_INLINE_DISPLAY": "true",
|
||||
"DREAMTAIL_INLINE_QUALITY": "85"
|
||||
}
|
||||
```
|
||||
|
||||
**Options:**
|
||||
- `DREAMTAIL_INLINE_DISPLAY`: `"true"` (default) or `"false"`
|
||||
- `"true"`: Images display inline in Claude Desktop (recommended)
|
||||
- `"false"`: Return text message with file path only
|
||||
- `DREAMTAIL_INLINE_QUALITY`: 50-95 (default: 85)
|
||||
- JPEG compression quality for inline display
|
||||
- Lower values = smaller files (required for 1MB limit)
|
||||
- 85: Good balance of quality and size (~150-300KB)
|
||||
- 70-75: More aggressive compression for large images
|
||||
- Will automatically reduce if image still exceeds 1MB
|
||||
|
||||
**Why inline quality is lower than saved quality:**
|
||||
- Claude Desktop has a 1MB limit for tool responses
|
||||
- Images must be compressed to fit this limit
|
||||
- Full-resolution images are always saved to disk at your configured quality
|
||||
- You get the best of both worlds: instant viewing + high-quality archives
|
||||
|
||||
### Image Format Options
|
||||
|
||||
Control saved file format in your Claude Desktop config:
|
||||
|
||||
```json
|
||||
"env": {
|
||||
"DREAMTAIL_BASE_URL": "http://192.168.50.30:8765",
|
||||
"DREAMTAIL_DOWNLOAD_DIR": "/Users/yourname/Pictures/dreamtail",
|
||||
"DREAMTAIL_FORMAT": "jpeg",
|
||||
"DREAMTAIL_JPEG_QUALITY": "95"
|
||||
}
|
||||
```
|
||||
|
||||
**Format Options:**
|
||||
- `"jpeg"` (default): Save as JPEG at specified quality
|
||||
- Quality 95: Visually lossless, ~10x smaller than PNG
|
||||
- Quality 85: Very good, ~15x smaller than PNG
|
||||
- Quality 75: Good for web, ~20x smaller than PNG
|
||||
- `"png"`: Save as PNG (lossless, ~2-5MB per image)
|
||||
- `"both"`: Save both JPEG and PNG versions
|
||||
|
||||
**File Size Comparison (1024x1024):**
|
||||
- PNG: ~2-5MB
|
||||
- JPEG Q95: ~200-500KB (recommended)
|
||||
- JPEG Q85: ~150-300KB
|
||||
- JPEG Q75: ~100-200KB
|
||||
|
||||
### Custom Download Directory
|
||||
|
||||
**Tip:** Use an absolute path, not `~`. For example:
|
||||
- ✅ `/Users/alex/Pictures/dreamtail`
|
||||
- ✅ `/home/alex/dreamtail_images`
|
||||
- ❌ `~/Pictures/dreamtail` (may not expand correctly in Claude Desktop)
|
||||
|
||||
If not specified, images will be saved to `~/dreamtail_images` by default.
|
||||
|
||||
### Custom Timeout
|
||||
|
||||
Edit `dreamtail_mcp.py` and change:
|
||||
```python
|
||||
DEFAULT_TIMEOUT = 180 # 3 minutes instead of 2
|
||||
```
|
||||
|
||||
### Different DreamTail Instance
|
||||
|
||||
In Claude Desktop config:
|
||||
```json
|
||||
"env": {
|
||||
"DREAMTAIL_BASE_URL": "http://192.168.50.30:8765"
|
||||
}
|
||||
```
|
||||
|
||||
### Using uv (Python Package Manager)
|
||||
|
||||
In Claude Desktop config:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"dreamtail": {
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"--directory",
|
||||
"/home/alex/Projects/dreamtail-mcp",
|
||||
"run",
|
||||
"dreamtail_mcp.py"
|
||||
],
|
||||
"env": {
|
||||
"DREAMTAIL_BASE_URL": "http://bigorin:8765"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ Claude Desktop │
|
||||
└────────┬────────┘
|
||||
│ stdio (MCP)
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ dreamtail_mcp │ (this server)
|
||||
└────────┬────────┘
|
||||
│ HTTP REST
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ DreamTail │ (bigorin:8765)
|
||||
│ SDXL Service │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
**Flow:**
|
||||
1. User asks Claude to generate an image
|
||||
2. Claude calls `dreamtail_generate()` via MCP
|
||||
3. MCP server submits job to DreamTail API
|
||||
4. MCP server polls status every 3 seconds
|
||||
5. When complete, downloads image to local folder
|
||||
6. Claude shows the user the local file path
|
||||
|
||||
## Related Projects
|
||||
|
||||
- **DreamTail**: SDXL image generation service (../dreamtail)
|
||||
- **Lyra**: AI assistant ecosystem
|
||||
- **Model Context Protocol**: https://modelcontextprotocol.io
|
||||
|
||||
## License
|
||||
|
||||
Part of the Lyra project ecosystem.
|
||||
|
||||
---
|
||||
|
||||
**Built with ❤️ for Claude Desktop integration**
|
||||
18
claude_desktop_config.example.json
Executable file
18
claude_desktop_config.example.json
Executable file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"dreamtail": {
|
||||
"command": "python3",
|
||||
"args": [
|
||||
"/home/alex/Projects/dreamtail-mcp/dreamtail_mcp.py"
|
||||
],
|
||||
"env": {
|
||||
"DREAMTAIL_BASE_URL": "http://192.168.50.30:8765",
|
||||
"DREAMTAIL_DOWNLOAD_DIR": "/Users/yourname/Pictures/dreamtail",
|
||||
"DREAMTAIL_FORMAT": "jpeg",
|
||||
"DREAMTAIL_JPEG_QUALITY": "95",
|
||||
"DREAMTAIL_INLINE_DISPLAY": "true",
|
||||
"DREAMTAIL_INLINE_QUALITY": "85"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
377
dreamtail_mcp.py
Executable file
377
dreamtail_mcp.py
Executable file
@@ -0,0 +1,377 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
DreamTail MCP Server
|
||||
|
||||
Model Context Protocol server for integrating DreamTail SDXL image generation
|
||||
with Claude Desktop.
|
||||
|
||||
Provides a single tool: dreamtail_generate() that submits a job, polls for
|
||||
completion, and returns the image URL.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional, List, Union, Dict, Any
|
||||
from pathlib import Path
|
||||
from io import BytesIO
|
||||
from datetime import datetime
|
||||
import httpx
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.utilities.types import Image as MCPImage
|
||||
from PIL import Image
|
||||
|
||||
# Configure logging to file for debugging
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler('/tmp/dreamtail_mcp.log'),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Initialize MCP server
|
||||
mcp = FastMCP("DreamTail Image Generator")
|
||||
|
||||
# Configuration from environment
|
||||
DREAMTAIL_BASE_URL = os.getenv("DREAMTAIL_BASE_URL", "http://bigorin.local:8765")
|
||||
DOWNLOAD_DIR = os.getenv("DREAMTAIL_DOWNLOAD_DIR", os.path.expanduser("~/dreamtail_images"))
|
||||
DOWNLOAD_FORMAT = os.getenv("DREAMTAIL_FORMAT", "jpeg") # "jpeg", "png", or "both"
|
||||
JPEG_QUALITY = int(os.getenv("DREAMTAIL_JPEG_QUALITY", "95")) # 1-100
|
||||
INLINE_DISPLAY = os.getenv("DREAMTAIL_INLINE_DISPLAY", "true").lower() == "true" # Enable inline display in Claude
|
||||
INLINE_QUALITY = int(os.getenv("DREAMTAIL_INLINE_QUALITY", "85")) # Quality for inline display (70-95)
|
||||
MAX_INLINE_SIZE_MB = 0.90 # Leave margin below 1MB limit (accounting for dict metadata)
|
||||
DEFAULT_POLL_INTERVAL = 3 # seconds
|
||||
DEFAULT_TIMEOUT = 120 # seconds (2 minutes)
|
||||
|
||||
# Create download directory if it doesn't exist
|
||||
Path(DOWNLOAD_DIR).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Track last generated image info
|
||||
last_generation_info: Optional[Dict[str, Any]] = None
|
||||
logger.info(f"Download directory: {DOWNLOAD_DIR}")
|
||||
logger.info(f"Download format: {DOWNLOAD_FORMAT} (JPEG quality: {JPEG_QUALITY})")
|
||||
logger.info(f"Inline display: {INLINE_DISPLAY} (quality: {INLINE_QUALITY})")
|
||||
|
||||
|
||||
async def download_image(client: httpx.AsyncClient, image_url: str, job_id: str) -> List[str]:
|
||||
"""
|
||||
Download the generated image to local storage with format conversion.
|
||||
|
||||
Args:
|
||||
client: httpx AsyncClient instance
|
||||
image_url: URL to download the image from
|
||||
job_id: Job ID to use as filename
|
||||
|
||||
Returns:
|
||||
List of local file paths where images were saved
|
||||
"""
|
||||
logger.info(f"Downloading image from {image_url}")
|
||||
|
||||
# Download the image
|
||||
response = await client.get(image_url)
|
||||
response.raise_for_status()
|
||||
|
||||
# Open image with PIL
|
||||
img = Image.open(BytesIO(response.content))
|
||||
|
||||
saved_paths = []
|
||||
|
||||
# Save based on format preference
|
||||
if DOWNLOAD_FORMAT in ["jpeg", "both"]:
|
||||
# Convert RGBA to RGB if needed (JPEG doesn't support transparency)
|
||||
if img.mode in ("RGBA", "LA", "P"):
|
||||
rgb_img = Image.new("RGB", img.size, (255, 255, 255))
|
||||
rgb_img.paste(img, mask=img.split()[-1] if img.mode == "RGBA" else None)
|
||||
img_to_save = rgb_img
|
||||
else:
|
||||
img_to_save = img
|
||||
|
||||
jpeg_path = Path(DOWNLOAD_DIR) / f"{job_id}.jpg"
|
||||
img_to_save.save(jpeg_path, "JPEG", quality=JPEG_QUALITY, optimize=True)
|
||||
saved_paths.append(str(jpeg_path))
|
||||
logger.info(f"JPEG saved: {jpeg_path} (quality {JPEG_QUALITY})")
|
||||
|
||||
if DOWNLOAD_FORMAT in ["png", "both"]:
|
||||
png_path = Path(DOWNLOAD_DIR) / f"{job_id}.png"
|
||||
img.save(png_path, "PNG")
|
||||
saved_paths.append(str(png_path))
|
||||
logger.info(f"PNG saved: {png_path}")
|
||||
|
||||
if not saved_paths:
|
||||
# Fallback to PNG if invalid format specified
|
||||
png_path = Path(DOWNLOAD_DIR) / f"{job_id}.png"
|
||||
img.save(png_path, "PNG")
|
||||
saved_paths.append(str(png_path))
|
||||
logger.warning(f"Invalid format '{DOWNLOAD_FORMAT}', defaulting to PNG: {png_path}")
|
||||
|
||||
return saved_paths
|
||||
|
||||
|
||||
async def prepare_inline_image(client: httpx.AsyncClient, image_url: str) -> Optional[MCPImage]:
|
||||
"""
|
||||
Prepare image for inline display in Claude Desktop.
|
||||
|
||||
Compresses image to stay under 1MB limit while maintaining visual quality.
|
||||
|
||||
Args:
|
||||
client: httpx AsyncClient instance
|
||||
image_url: URL to download the image from
|
||||
|
||||
Returns:
|
||||
MCPImage object for inline display, or None if preparation fails
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Preparing inline image from {image_url}")
|
||||
|
||||
# Download the image
|
||||
response = await client.get(image_url)
|
||||
response.raise_for_status()
|
||||
|
||||
# Open with PIL
|
||||
pil_image = Image.open(BytesIO(response.content))
|
||||
|
||||
# Convert to RGB if needed (JPEG doesn't support transparency)
|
||||
if pil_image.mode in ("RGBA", "LA", "P"):
|
||||
rgb_image = Image.new("RGB", pil_image.size, (255, 255, 255))
|
||||
if pil_image.mode == "RGBA":
|
||||
rgb_image.paste(pil_image, mask=pil_image.split()[-1])
|
||||
else:
|
||||
rgb_image.paste(pil_image)
|
||||
pil_image = rgb_image
|
||||
elif pil_image.mode != "RGB":
|
||||
pil_image = pil_image.convert("RGB")
|
||||
|
||||
# Try compression at configured quality first
|
||||
buffer = BytesIO()
|
||||
pil_image.save(buffer, format="JPEG", quality=INLINE_QUALITY, optimize=True)
|
||||
img_bytes = buffer.getvalue()
|
||||
size_mb = len(img_bytes) / (1024 * 1024)
|
||||
|
||||
logger.info(f"Initial compression: {size_mb:.2f}MB at quality {INLINE_QUALITY}")
|
||||
|
||||
# If still too large, try lower quality
|
||||
if size_mb > MAX_INLINE_SIZE_MB:
|
||||
quality = INLINE_QUALITY - 10
|
||||
while quality >= 50 and size_mb > MAX_INLINE_SIZE_MB:
|
||||
buffer = BytesIO()
|
||||
pil_image.save(buffer, format="JPEG", quality=quality, optimize=True)
|
||||
img_bytes = buffer.getvalue()
|
||||
size_mb = len(img_bytes) / (1024 * 1024)
|
||||
logger.info(f"Recompressed: {size_mb:.2f}MB at quality {quality}")
|
||||
quality -= 10
|
||||
|
||||
# If still too large, try resizing
|
||||
if size_mb > MAX_INLINE_SIZE_MB:
|
||||
scale = 0.8
|
||||
while scale >= 0.5 and size_mb > MAX_INLINE_SIZE_MB:
|
||||
new_size = (int(pil_image.width * scale), int(pil_image.height * scale))
|
||||
resized = pil_image.resize(new_size, Image.Resampling.LANCZOS)
|
||||
buffer = BytesIO()
|
||||
resized.save(buffer, format="JPEG", quality=75, optimize=True)
|
||||
img_bytes = buffer.getvalue()
|
||||
size_mb = len(img_bytes) / (1024 * 1024)
|
||||
logger.info(f"Resized to {new_size}: {size_mb:.2f}MB")
|
||||
scale -= 0.1
|
||||
|
||||
# Final check
|
||||
if size_mb > MAX_INLINE_SIZE_MB:
|
||||
logger.warning(f"Image still too large ({size_mb:.2f}MB), cannot display inline")
|
||||
return None
|
||||
|
||||
logger.info(f"✓ Inline image ready: {size_mb:.2f}MB")
|
||||
return MCPImage(data=img_bytes, format="jpeg")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to prepare inline image: {e}")
|
||||
return None
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def dreamtail_generate(
|
||||
prompt: str,
|
||||
negative_prompt: Optional[str] = None,
|
||||
width: int = 1024,
|
||||
height: int = 1024,
|
||||
num_inference_steps: int = 30,
|
||||
client_id: str = "claude"
|
||||
) -> Union[MCPImage, str]:
|
||||
"""
|
||||
Generate an image using SDXL on the Jetson Orin.
|
||||
|
||||
This tool submits an image generation job to DreamTail, waits for it to
|
||||
complete (polling every 3 seconds), and returns the generated image.
|
||||
|
||||
When inline display is enabled (default), the image is shown directly in
|
||||
Claude Desktop. Full-resolution images are also saved to your local folder.
|
||||
|
||||
Generation typically takes 45-60 seconds for a 1024x1024 image.
|
||||
|
||||
Args:
|
||||
prompt: Text description of the image to generate
|
||||
negative_prompt: Optional text describing what to avoid in the image
|
||||
width: Image width in pixels (must be multiple of 8, default 1024)
|
||||
height: Image height in pixels (must be multiple of 8, default 1024)
|
||||
num_inference_steps: Number of denoising steps (20-50, default 30)
|
||||
client_id: Client identifier (default "claude")
|
||||
|
||||
Returns:
|
||||
MCPImage object for inline display (if enabled and successful),
|
||||
or text message with file paths and filename
|
||||
"""
|
||||
try:
|
||||
logger.info("=" * 60)
|
||||
logger.info(f"TOOL CALLED: dreamtail_generate")
|
||||
logger.info(f"DreamTail base URL: {DREAMTAIL_BASE_URL}")
|
||||
logger.info(f"Prompt: {prompt[:50]}...")
|
||||
logger.info(f"Inline display: {INLINE_DISPLAY}")
|
||||
logger.info("=" * 60)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in initial logging: {e}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
try:
|
||||
# Step 1: Submit generation job
|
||||
submit_url = f"{DREAMTAIL_BASE_URL}/generate"
|
||||
logger.debug(f"Submitting to: {submit_url}")
|
||||
|
||||
submit_response = await client.post(
|
||||
submit_url,
|
||||
json={
|
||||
"prompt": prompt,
|
||||
"client_id": client_id,
|
||||
"negative_prompt": negative_prompt,
|
||||
"params": {
|
||||
"width": width,
|
||||
"height": height,
|
||||
"num_inference_steps": num_inference_steps
|
||||
}
|
||||
}
|
||||
)
|
||||
submit_response.raise_for_status()
|
||||
job_data = submit_response.json()
|
||||
job_id = job_data["job_id"]
|
||||
logger.info(f"Job submitted successfully: {job_id}")
|
||||
|
||||
# Step 2: Poll for completion
|
||||
elapsed = 0
|
||||
last_progress = 0
|
||||
|
||||
while elapsed < DEFAULT_TIMEOUT:
|
||||
# Check job status
|
||||
status_response = await client.get(
|
||||
f"{DREAMTAIL_BASE_URL}/status/{job_id}"
|
||||
)
|
||||
status_response.raise_for_status()
|
||||
status_data = status_response.json()
|
||||
|
||||
current_status = status_data["status"]
|
||||
last_progress = status_data.get("progress", 0)
|
||||
|
||||
# Job completed successfully
|
||||
if current_status == "completed":
|
||||
image_url = f"{DREAMTAIL_BASE_URL}/result/{job_id}"
|
||||
|
||||
# Try inline display if enabled
|
||||
if INLINE_DISPLAY:
|
||||
try:
|
||||
inline_image = await prepare_inline_image(client, image_url)
|
||||
|
||||
# Download full-resolution to local storage
|
||||
local_paths = []
|
||||
try:
|
||||
local_paths = await download_image(client, image_url, job_id)
|
||||
logger.info(f"Saved full-res to: {', '.join(local_paths)}")
|
||||
except Exception as save_error:
|
||||
logger.warning(f"Failed to save full-res locally: {save_error}")
|
||||
|
||||
# Return inline image if successful
|
||||
if inline_image:
|
||||
logger.info("✓ Inline image prepared successfully")
|
||||
|
||||
# Store info about this generation
|
||||
global last_generation_info
|
||||
filename = Path(local_paths[0]).name if local_paths else f"{job_id}.jpg"
|
||||
last_generation_info = {
|
||||
"job_id": job_id,
|
||||
"filename": filename,
|
||||
"saved_paths": local_paths,
|
||||
"prompt": prompt[:100], # Truncate long prompts
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
# Note: We return just the MCPImage, not wrapped in a dict
|
||||
# Use get_last_dreamtail_info() to retrieve filename and paths
|
||||
return inline_image
|
||||
else:
|
||||
logger.warning("Inline image preparation failed, falling back to file path")
|
||||
|
||||
except Exception as inline_error:
|
||||
logger.error(f"Inline display error: {inline_error}")
|
||||
|
||||
# Fallback: Download and return message with file paths
|
||||
try:
|
||||
local_paths = await download_image(client, image_url, job_id)
|
||||
|
||||
# Format message based on number of files
|
||||
if len(local_paths) == 1:
|
||||
filename = Path(local_paths[0]).name
|
||||
message = f"✓ Image generated successfully!\n\nFilename: {filename}\nSaved to: {local_paths[0]}\nJob ID: {job_id}"
|
||||
else:
|
||||
filename = Path(local_paths[0]).name
|
||||
paths_str = "\n".join([f" - {p}" for p in local_paths])
|
||||
message = f"✓ Image generated successfully!\n\nFilename: {filename}\nSaved to:\n{paths_str}\nJob ID: {job_id}"
|
||||
|
||||
return message
|
||||
except Exception as download_error:
|
||||
logger.error(f"Failed to download image: {download_error}")
|
||||
return f"Image generated successfully but download failed: {download_error}. You can download it manually from: {image_url}"
|
||||
|
||||
# Job failed
|
||||
if current_status == "failed":
|
||||
error = status_data.get("error", "Unknown error")
|
||||
return f"❌ Image generation failed: {error}"
|
||||
|
||||
# Still processing, wait and poll again
|
||||
await asyncio.sleep(DEFAULT_POLL_INTERVAL)
|
||||
elapsed += DEFAULT_POLL_INTERVAL
|
||||
|
||||
# Timeout reached
|
||||
return f"⏱️ Generation exceeded {DEFAULT_TIMEOUT} second timeout. Job {job_id} is still processing. Check status at: {DREAMTAIL_BASE_URL}/status/{job_id}"
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.exception(f"HTTPStatusError in dreamtail_generate: {e}")
|
||||
return f"❌ DreamTail API error: {e.response.status_code} - {e.response.text}"
|
||||
except httpx.RequestError as e:
|
||||
logger.exception(f"RequestError in dreamtail_generate: {e}")
|
||||
return f"❌ Failed to connect to DreamTail at {DREAMTAIL_BASE_URL}: {str(e)}"
|
||||
except Exception as e:
|
||||
logger.exception(f"Unexpected error in dreamtail_generate: {e}")
|
||||
return f"❌ Unexpected error: {str(e)}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def dreamtail_get_info() -> str:
|
||||
"""
|
||||
Get the file path of the last image generated by DreamTail.
|
||||
|
||||
Useful for getting the file path after dreamtail_generate() returns an image,
|
||||
so you can send it to Matrix with send_matrix_image_from_file().
|
||||
|
||||
Returns:
|
||||
File path of the last generated image, or error message if none exists
|
||||
"""
|
||||
global last_generation_info
|
||||
|
||||
if last_generation_info is None:
|
||||
return "No DreamTail images generated yet in this session"
|
||||
|
||||
# Return just the first saved path (primary file)
|
||||
return last_generation_info['saved_paths'][0]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Run the MCP server (uses stdio transport by default)
|
||||
mcp.run()
|
||||
3
requirements.txt
Executable file
3
requirements.txt
Executable file
@@ -0,0 +1,3 @@
|
||||
fastmcp
|
||||
httpx
|
||||
Pillow
|
||||
51
test_tool.py
Executable file
51
test_tool.py
Executable file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Quick test script to verify dreamtail_generate works
|
||||
Run this to test the tool without Claude Desktop
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
sys.path.insert(0, '/home/alex/Projects/dreamtail-mcp')
|
||||
|
||||
# Import the actual function by accessing it from the tool wrapper
|
||||
import dreamtail_mcp
|
||||
|
||||
async def test():
|
||||
print("Testing dreamtail_generate tool...")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
# Get the actual function from the tool wrapper
|
||||
tool = dreamtail_mcp.mcp._tool_manager._tools['dreamtail_generate']
|
||||
actual_function = tool.fn
|
||||
|
||||
result = await actual_function(
|
||||
prompt="a simple test image of a red circle",
|
||||
width=512, # Smaller for faster testing
|
||||
height=512,
|
||||
num_inference_steps=20 # Fewer steps for faster testing
|
||||
)
|
||||
|
||||
print("\n✓ Tool executed successfully!")
|
||||
print(f"Result type: {type(result)}")
|
||||
print(f"Result: {result}")
|
||||
|
||||
# If it's an Image object, check its properties
|
||||
if hasattr(result, 'data'):
|
||||
print(f"Image data size: {len(result.data)} bytes")
|
||||
print(f"Image format: {result.format if hasattr(result, 'format') else 'unknown'}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ Tool failed with error:")
|
||||
print(f"Error type: {type(e).__name__}")
|
||||
print(f"Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = asyncio.run(test())
|
||||
sys.exit(0 if success else 1)
|
||||
Reference in New Issue
Block a user