This commit is contained in:
moosecrap 2026-07-22 22:53:15 -07:00
parent d87fcb85be
commit 48132aa21c
6 changed files with 149 additions and 135 deletions

View File

@ -3,6 +3,7 @@ import uuid
import json import json
from typing import Any, Dict, Union, List from typing import Any, Dict, Union, List
from starlette.responses import Response from starlette.responses import Response
from tools.utils import ToolError
class MCPServer: class MCPServer:
def __init__(self): def __init__(self):
@ -40,7 +41,14 @@ class MCPServer:
media_type="application/json" media_type="application/json"
) )
try:
result_data = await tool.handler(args) result_data = await tool.handler(args)
except ToolError as e:
# Convert tool errors into a text response so the LLM can understand and potentially fix the input
result_data = {"text": str(e)}
except Exception as e:
# Catch-all for unexpected crashes to prevent server death
result_data = {"text": f"Unexpected internal error: {str(e)}"}
# Handle results that are already lists of content (for multimodal/multi-part responses) # Handle results that are already lists of content (for multimodal/multi-part responses)
if isinstance(result_data, list): if isinstance(result_data, list):

View File

@ -1,14 +1,11 @@
import os
import base64 import base64
import mimetypes
import logging import logging
import asyncio
import io import io
import datetime import datetime
import time from pathlib import Path
from typing import Any, Callable, Dict, List, Union from typing import Any, Dict, List
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from tools.utils import format_relative_time from tools.utils import format_relative_time, ToolError, COMMON_FONTS, get_file_info_list, sort_file_list, get_paginated_list
logger = logging.getLogger("MattCP") logger = logging.getLogger("MattCP")
@ -18,44 +15,32 @@ async def handle(args: Dict[str, Any]):
Grid: 10 columns x 7 rows (70 images total). Grid: 10 columns x 7 rows (70 images total).
Sized for optimal token usage (1120 tokens) on llama.cpp. Sized for optimal token usage (1120 tokens) on llama.cpp.
""" """
dir_path = args.get("path") dir_path_str = args.get("path")
page = int(args.get("page", 1)) page = int(args.get("page", 1))
sort_by = args.get("sort_by", "mtime") sort_by = args.get("sort_by", "mtime")
if not dir_path: if not dir_path_str:
return {"text": "Error: Missing path argument"} raise ToolError("Missing path argument")
if not os.path.isdir(dir_path):
return {"text": f"Error: {dir_path} is not a directory."} dir_path = Path(dir_path_str)
if not dir_path.is_dir():
raise ToolError(f"{dir_path_str} is not a directory.")
exts = ('.png', '.jpg', '.jpeg', '.webp', '.bmp') exts = ('.png', '.jpg', '.jpeg', '.webp', '.bmp')
file_info_list = [] file_info_list = get_file_info_list(dir_path, extensions=exts)
for f in os.listdir(dir_path):
if f.lower().endswith(exts):
full_path = os.path.join(dir_path, f)
try:
stats = os.stat(full_path)
file_info_list.append({
"name": f,
"path": full_path,
"mtime": stats.st_mtime,
"size": stats.st_size
})
except Exception as e:
logger.error(f"Could not stat {f}: {e}")
if not file_info_list: if not file_info_list:
return {"text": "No supported images found in the directory."} return {"text": "No supported images found in the directory."}
# Sorting # Sorting
if sort_by == "mtime": file_info_list = sort_file_list(file_info_list, sort_by)
file_info_list.sort(key=lambda x: x["mtime"], reverse=True)
sort_label = "Date (newest first)" sort_labels = {
elif sort_by == "size": "mtime": "Date (newest first)",
file_info_list.sort(key=lambda x: x["size"], reverse=True) "size": "Size (largest first)",
sort_label = "Size (largest first)" "name": "Name (alphabetical)"
else: }
file_info_list.sort(key=lambda x: x["name"].lower()) sort_label = sort_labels.get(sort_by, "Name (alphabetical)")
sort_label = "Name (alphabetical)"
# Fixed grid dimensions for token optimization # Fixed grid dimensions for token optimization
COLS = 10 COLS = 10
@ -63,15 +48,12 @@ async def handle(args: Dict[str, Any]):
PAGE_SIZE = COLS * ROWS PAGE_SIZE = COLS * ROWS
total_files = len(file_info_list) total_files = len(file_info_list)
start_idx = (page - 1) * PAGE_SIZE paged_files = get_paginated_list(file_info_list, page, PAGE_SIZE)
end_idx = start_idx + PAGE_SIZE
paged_files = file_info_list[start_idx:end_idx]
if not paged_files: if not paged_files:
return {"text": f"No images found on page {page}."} return {"text": f"No images found on page {page}."}
thumb_size = 192 # 192 / 48 = 4 patches per side thumb_size = 192 # 192 / 48 = 4 patches per side
canvas_w = COLS * thumb_size canvas_w = COLS * thumb_size
canvas_h = ROWS * thumb_size canvas_h = ROWS * thumb_size
@ -80,21 +62,16 @@ async def handle(args: Dict[str, Any]):
# Font loading # Font loading
font = None font = None
font_paths = [ for font_candidate in COMMON_FONTS:
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
"/usr/share/fonts/truetype/freefont/FreeSansBold.ttf",
]
for path in font_paths:
if os.path.exists(path):
try: try:
font = ImageFont.truetype(path, 24) font = ImageFont.truetype(font_candidate, 24)
break break
except Exception: except Exception:
continue continue
if font is None: if font is None:
font = ImageFont.load_default() font = ImageFont.load_default()
start_idx = (page - 1) * PAGE_SIZE
for i, info in enumerate(paged_files): for i, info in enumerate(paged_files):
full_path = info["path"] full_path = info["path"]
row = i // COLS row = i // COLS
@ -102,7 +79,6 @@ async def handle(args: Dict[str, Any]):
x = col * thumb_size x = col * thumb_size
y = row * thumb_size y = row * thumb_size
global_index = start_idx + i + 1 global_index = start_idx + i + 1
try: try:
@ -112,17 +88,13 @@ async def handle(args: Dict[str, Any]):
off_y = (thumb_size - img.height) // 2 off_y = (thumb_size - img.height) // 2
canvas.paste(img, (x + off_x, y + off_y)) canvas.paste(img, (x + off_x, y + off_y))
# Index number in lower-left corner
text = str(global_index) text = str(global_index)
if font: if font:
bbox = draw.textbbox((0, 0), text, font=font) bbox = draw.textbbox((0, 0), text, font=font)
text_w = bbox[2] - bbox[0] text_w, text_h = bbox[2] - bbox[0], bbox[3] - bbox[1]
text_h = bbox[3] - bbox[1]
else: else:
text_w = len(text) * 8 text_w, text_h = len(text) * 8, 15
text_h = 15
# Semi-opaque background rectangle for the number
draw.rectangle([x, y + thumb_size - text_h - 5, x + text_w + 5, y + thumb_size - 2], fill=(0, 0, 0, 180)) draw.rectangle([x, y + thumb_size - text_h - 5, x + text_w + 5, y + thumb_size - 2], fill=(0, 0, 0, 180))
draw.text((x + 2, y + thumb_size - text_h - 7), text, fill=(255, 255, 255), font=font) draw.text((x + 2, y + thumb_size - text_h - 7), text, fill=(255, 255, 255), font=font)
@ -136,7 +108,6 @@ async def handle(args: Dict[str, Any]):
total_pages = (total_files + PAGE_SIZE - 1) // PAGE_SIZE total_pages = (total_files + PAGE_SIZE - 1) // PAGE_SIZE
# Range shown: first and last of the CURRENT PAGE
first_on_page = paged_files[0] first_on_page = paged_files[0]
last_on_page = paged_files[-1] last_on_page = paged_files[-1]
@ -151,7 +122,7 @@ async def handle(args: Dict[str, Any]):
last_val = last_on_page['name'] last_val = last_on_page['name']
header_text = ( header_text = (
f"Directory: {dir_path}\n" f"Directory: {dir_path_str}\n"
f"Page {page} of {total_pages} ({total_files} images total). Sorted by: {sort_label}\n" f"Page {page} of {total_pages} ({total_files} images total). Sorted by: {sort_label}\n"
f"Range shown: {first_val} ... {last_val}" f"Range shown: {first_val} ... {last_val}"
) )

View File

@ -1,8 +1,7 @@
import os
import logging import logging
import datetime from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from tools.utils import format_relative_time from tools.utils import format_relative_time, ToolError, get_file_info_list, sort_file_list
logger = logging.getLogger("MattCP") logger = logging.getLogger("MattCP")
@ -12,65 +11,24 @@ async def handle(args: Dict[str, Any]):
Directories are listed first, then files. Directories are listed first, then files.
Supports pagination and sorting. Supports pagination and sorting.
""" """
path = args.get("path") path_str = args.get("path")
page = int(args.get("page", 1)) page = int(args.get("page", 1))
page_size = int(args.get("page_size", 64)) page_size = int(args.get("page_size", 64))
sort_by = args.get("sort_by", "mtime") sort_by = args.get("sort_by", "mtime")
if not path: if not path_str:
return {"text": "Error: Missing path argument"} raise ToolError("Missing path argument")
if not os.path.isdir(path):
return {"text": f"Error: {path} is not a directory."}
try: path = Path(path_str)
entries = os.listdir(path) if not path.is_dir():
except Exception as e: raise ToolError(f"{path_str} is not a directory.")
return {"text": f"Error reading directory: {e}"}
all_items = [] all_items = get_file_info_list(path)
for entry in entries: all_items = sort_file_list(all_items, sort_by)
full_path = os.path.join(path, entry)
try:
stats = os.stat(full_path)
mtime_rel = format_relative_time(stats.st_mtime)
if os.path.isdir(full_path):
try:
count = len(os.listdir(full_path))
except:
count = 0
all_items.append({
"type": "dir",
"name": entry,
"info": f"{count} items",
"mtime": mtime_rel,
"mtime_raw": stats.st_mtime,
"size": stats.st_size
})
else:
size_kb = stats.st_size / 1024
all_items.append({
"type": "file",
"name": entry,
"info": f"{size_kb:.1f}KB",
"mtime": mtime_rel,
"mtime_raw": stats.st_mtime,
"size": stats.st_size
})
except Exception as e:
logger.error(f"Could not stat {entry}: {e}")
# Sort Logic: Directories always come first, then apply secondary sort
if sort_by == "mtime":
all_items.sort(key=lambda x: (x["type"] == "file", -x["mtime_raw"]))
elif sort_by == "size":
all_items.sort(key=lambda x: (x["type"] == "file", -x["size"]))
else:
all_items.sort(key=lambda x: (x["type"] == "file", x["name"].lower()))
total_items = len(all_items) total_items = len(all_items)
# Custom Pagination: show all if <= 100, otherwise paginate by 64 # Custom Pagination: show all if <= 100, otherwise paginate by page_size
if total_items <= 100: if total_items <= 100:
start_idx = 0 start_idx = 0
end_idx = total_items end_idx = total_items
@ -89,11 +47,18 @@ async def handle(args: Dict[str, Any]):
output = [] output = []
for item in paged_items: for item in paged_items:
if item["type"] == "dir": mtime_rel = format_relative_time(item["mtime"])
output.append(f"[DIR] {item['name']} | {item['info']} | {item['mtime']}") if item["is_dir"]:
try:
count = len(list(item["path"].iterdir()))
info = f"{count} items"
except:
info = "0 items"
output.append(f"[DIR] {item['name']} | {info} | {mtime_rel}")
else: else:
output.append(f" {item['name']} | {item['info']} | {item['mtime']}") size_kb = item["size"] / 1024
output.append(f" {item['name']} | {size_kb:.1f}KB | {mtime_rel}")
header = f"Listing of {path}\nPage {effective_page} of {total_pages} ({total_items} total items). Showing {start_idx+1}-{min(end_idx, total_items)}." header = f"Listing of {path_str}\nPage {effective_page} of {total_pages} ({total_items} total items). Showing {start_idx+1}-{min(end_idx, total_items)}."
return {"text": f"{header}\n\n" + "\n".join(output)} return {"text": f"{header}\n\n" + "\n".join(output)}

View File

@ -1,8 +1,9 @@
import os
import base64 import base64
import mimetypes import mimetypes
import logging import logging
from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from tools.utils import ToolError
logger = logging.getLogger("MattCP") logger = logging.getLogger("MattCP")
@ -11,19 +12,19 @@ async def handle(args: Dict[str, Any]):
Reads an image file from disk and returns it as a base64 encoded string Reads an image file from disk and returns it as a base64 encoded string
within an MCP ImageContent object. within an MCP ImageContent object.
""" """
path = args.get("path") path_str = args.get("path")
if not path: if not path_str:
return {"text": "Error: Missing path argument"} raise ToolError("Missing path argument")
path = Path(path_str)
# Validate that the file has an image-like MIME type # Validate that the file has an image-like MIME type
mime_type, _ = mimetypes.guess_type(path) mime_type, _ = mimetypes.guess_type(path_str)
if not mime_type or not mime_type.startswith("image/"): if not mime_type or not mime_type.startswith("image/"):
return {"text": f"Error: File {path} is not a supported image format."} raise ToolError(f"File {path_str} is not a supported image format.")
try: try:
with open(path, "rb") as f: data = base64.b64encode(path.read_bytes()).decode("utf-8")
# Read raw bytes and encode to base64 for transport
data = base64.b64encode(f.read()).decode("utf-8")
return {"type": "image", "data": data, "mimeType": mime_type} return {"type": "image", "data": data, "mimeType": mime_type}
except Exception as e: except Exception as e:
return {"text": f"Error reading image: {str(e)}"} raise ToolError(f"Error reading image: {str(e)}")

View File

@ -1,7 +1,8 @@
import os
import logging import logging
from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from PIL import Image from PIL import Image
from tools.utils import ToolError
logger = logging.getLogger("MattCP") logger = logging.getLogger("MattCP")
@ -10,15 +11,17 @@ async def handle(args: Dict[str, Any]):
Extracts text-based metadata chunks (tEXt, zTXt, iTXt) from a PNG file. Extracts text-based metadata chunks (tEXt, zTXt, iTXt) from a PNG file.
These often contain AI generation prompts and parameters. These often contain AI generation prompts and parameters.
""" """
path = args.get("path") path_str = args.get("path")
if not path: if not path_str:
return {"text": "Error: Missing path argument"} raise ToolError("Missing path argument")
path = Path(path_str)
try: try:
with Image.open(path) as img: with Image.open(path) as img:
# Metadata extraction is specific to PNG format in this implementation # Metadata extraction is specific to PNG format in this implementation
if img.format != 'PNG': if img.format != 'PNG':
return {"text": "Error: File is not a PNG image."} raise ToolError("File is not a PNG image.")
# Pillow parses PNG text chunks into the .info dictionary # Pillow parses PNG text chunks into the .info dictionary
metadata = img.info metadata = img.info
@ -28,5 +31,7 @@ async def handle(args: Dict[str, Any]):
# Format as a simple key: value list # Format as a simple key: value list
output = "\n".join([f"{k}: {v}" for k, v in metadata.items()]) output = "\n".join([f"{k}: {v}" for k, v in metadata.items()])
return {"text": f"PNG Metadata:\n{output}"} return {"text": f"PNG Metadata:\n{output}"}
except ToolError:
raise
except Exception as e: except Exception as e:
return {"text": f"Error reading PNG metadata: {str(e)}"} raise ToolError(f"Error reading PNG metadata: {str(e)}")

View File

@ -1,5 +1,11 @@
import time import time
import datetime import datetime
from pathlib import Path
from typing import List, Dict, Any, Optional
class ToolError(Exception):
"""Custom exception for tool-related errors to be caught by the MCP server."""
pass
def format_relative_time(timestamp: float) -> str: def format_relative_time(timestamp: float) -> str:
"""Converts a timestamp to a human-readable relative format.""" """Converts a timestamp to a human-readable relative format."""
@ -32,3 +38,61 @@ def format_relative_time(timestamp: float) -> str:
dt = datetime.datetime.fromtimestamp(timestamp) dt = datetime.datetime.fromtimestamp(timestamp)
return dt.strftime('%Y-%m-%d') return dt.strftime('%Y-%m-%d')
# Cross-platform font candidates
# ImageFont.truetype can often find these by name on Windows,
# or we can provide paths for Linux.
COMMON_FONTS = [
# Linux paths
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
"/usr/share/fonts/truetype/freefont/FreeSansBold.ttf",
# Windows filenames (Pillow often finds these in the system path)
"arialbd.ttf",
"calibrib.ttf",
"verdanab.ttf",
"tahomabd.ttf",
]
def get_file_info_list(path: Path, extensions: Optional[tuple] = None) -> List[Dict[str, Any]]:
"""
Gathers basic information about files in a directory.
Optional extensions filter (e.g. ('.png', '.jpg')).
"""
items = []
try:
for entry in path.iterdir():
if extensions and not entry.name.lower().endswith(extensions):
continue
stats = entry.stat()
items.append({
"name": entry.name,
"path": entry,
"mtime": stats.st_mtime,
"size": stats.st_size,
"is_dir": entry.is_dir()
})
except Exception as e:
raise ToolError(f"Error accessing directory {path}: {e}")
return items
def sort_file_list(items: List[Dict[str, Any]], sort_by: str) -> List[Dict[str, Any]]:
"""Sorts items based on the provided key. Directories always come first."""
if sort_by == "mtime":
# Newest first
items.sort(key=lambda x: (not x["is_dir"], -x["mtime"]))
elif sort_by == "size":
# Largest first
items.sort(key=lambda x: (not x["is_dir"], -x["size"]))
else:
# Alphabetical
items.sort(key=lambda x: (not x["is_dir"], x["name"].lower()))
return items
def get_paginated_list(items: List[Any], page: int, page_size: int) -> List[Any]:
"""Returns a slice of the list based on page and page_size."""
start_idx = (page - 1) * page_size
end_idx = start_idx + page_size
return items[start_idx:end_idx]