Files
mcp-comfyui/server.py
T
root 11a7a7aa45 fix: boto3 1.35+ Garage S3 checksum regression
Add request_checksum_calculation=when_required to boto3 Config to
prevent STREAMING-* sha256 header that Garage S3 rejects.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 09:29:29 +02:00

435 lines
16 KiB
Python

"""ComfyUI FLUX.1-schnell MCP server — async queue edition.
Tools:
generate_image — txt2img; submits job and returns job_id immediately
img2img — img2img for one or more input images; one job per image
get_job_status — poll status + retrieve completed images inline
list_queue — list recent jobs in the in-process registry
cancel_job — cancel a queued/running job (confirm with user first)
list_recent_images — show n most recent completed images for reference/chaining
Job chaining: pass "job:<job_id>" in the img2img images list to use a previous
output as input without routing bytes through the LLM.
"""
import base64
import copy
import io
import json
import os
import random
import threading
import time
import uuid
from datetime import datetime, timezone
import boto3
import httpx
from botocore.config import Config
from mcp.server.fastmcp import FastMCP, Image
COMFY = os.environ.get("COMFYUI_URL", "http://comfyui:8188")
# S3 upload (optional — skipped if credentials absent)
_S3_ENDPOINT = os.environ.get("CHAT_ARTIFACTS_ENDPOINT", "")
_S3_ACCESS = os.environ.get("CHAT_ARTIFACTS_ACCESS_KEY", "")
_S3_SECRET = os.environ.get("CHAT_ARTIFACTS_SECRET_KEY", "")
_S3_BUCKET = os.environ.get("CHAT_ARTIFACTS_BUCKET", "chat-artifacts")
_S3_PUBLIC_BASE = "https://chat-artifacts.s3.nuclide.systems"
_S3_ENABLED = bool(_S3_ENDPOINT and _S3_ACCESS and _S3_SECRET)
def _s3_upload_png(png: bytes, job_id: str) -> str | None:
if not _S3_ENABLED:
return None
key = datetime.now(timezone.utc).strftime("%Y-%m-%d/") + f"comfyui-{job_id}.png"
try:
boto3.client(
"s3",
endpoint_url=_S3_ENDPOINT,
aws_access_key_id=_S3_ACCESS,
aws_secret_access_key=_S3_SECRET,
region_name="garage",
config=Config(
signature_version="s3v4",
request_checksum_calculation="when_required",
response_checksum_validation="when_required",
),
).put_object(Bucket=_S3_BUCKET, Key=key, Body=png, ContentType="image/png")
return f"{_S3_PUBLIC_BASE}/{key}"
except Exception:
return None
_TXT2IMG_PATH = os.environ.get("WORKFLOW_PATH", "/app/flux-gguf-api.json")
_IMG2IMG_PATH = os.environ.get("IMG2IMG_WORKFLOW_PATH", "/app/flux-img2img-api.json")
POLL_INTERVAL = 3
GEN_DEADLINE = int(os.environ.get("GEN_DEADLINE", 480)) # seconds before blocking tools give up
with open(_TXT2IMG_PATH) as fh:
_BASE_TXT2IMG = json.load(fh)
with open(_IMG2IMG_PATH) as fh:
_BASE_IMG2IMG = json.load(fh)
mcp = FastMCP("comfyui-flux", host="0.0.0.0", port=8000, stateless_http=True)
# ---------------------------------------------------------------------------
# Job registry: job_id → {comfy_pid, status, kind, prompt, submitted_at,
# result_png, output_filename, error}
# ---------------------------------------------------------------------------
_jobs: dict[str, dict] = {}
_lock = threading.Lock()
def _register_job(comfy_pid: str, prompt: str, kind: str) -> str:
job_id = f"{kind[:3]}-{uuid.uuid4().hex[:8]}"
with _lock:
_jobs[job_id] = {
"comfy_pid": comfy_pid,
"status": "running",
"kind": kind,
"prompt": prompt,
"submitted_at": time.time(),
"result_png": None,
"output_filename": None,
"error": None,
}
return job_id
def _poll_loop():
while True:
time.sleep(POLL_INTERVAL)
with _lock:
pending = [(jid, j["comfy_pid"]) for jid, j in _jobs.items() if j["status"] == "running"]
for jid, cpid in pending:
try:
with httpx.Client(timeout=10) as c:
hist = c.get(f"{COMFY}/history/{cpid}").json()
if cpid not in hist:
continue
entry = hist[cpid]
st = entry.get("status", {})
if st.get("status_str") == "error":
with _lock:
_jobs[jid]["status"] = "error"
_jobs[jid]["error"] = str(st.get("messages", ""))[:500]
continue
imgs = [im for node in entry.get("outputs", {}).values() for im in node.get("images", [])]
if imgs:
im = imgs[0]
with httpx.Client(timeout=120) as c:
png = c.get(
f"{COMFY}/view",
params={"filename": im["filename"], "subfolder": im.get("subfolder", ""), "type": im.get("type", "output")},
).content
s3_url = _s3_upload_png(png, jid)
with _lock:
_jobs[jid]["status"] = "done"
_jobs[jid]["result_png"] = png
_jobs[jid]["output_filename"] = im["filename"]
_jobs[jid]["s3_url"] = s3_url
elif st.get("completed"):
with _lock:
_jobs[jid]["status"] = "error"
_jobs[jid]["error"] = "Completed with no output images"
except Exception:
pass
threading.Thread(target=_poll_loop, daemon=True).start()
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _upload_image(img_bytes: bytes, filename: str = "input.png") -> str:
"""Upload image bytes to ComfyUI /upload/image, return the filename assigned."""
with httpx.Client(timeout=60) as c:
r = c.post(
f"{COMFY}/upload/image",
files={"image": (filename, io.BytesIO(img_bytes), "image/png")},
data={"type": "input", "overwrite": "true"},
)
r.raise_for_status()
return r.json()["name"]
def _resolve_image(src: str) -> bytes:
"""Resolve an image source to raw bytes.
Accepts:
job:<job_id> — output of a previous completed job
https://... / http://... — downloaded URL
data:image/...;base64,... — inline base64
"""
if src.startswith("job:"):
jid = src[4:].strip()
with _lock:
job = _jobs.get(jid)
if not job:
raise ValueError(f"Unknown job: {jid}")
if job["status"] != "done":
raise ValueError(f"Job {jid} not done yet (status: {job['status']})")
return job["result_png"]
if src.startswith("data:"):
_, payload = src.split(",", 1)
return base64.b64decode(payload)
with httpx.Client(timeout=60, follow_redirects=True) as c:
r = c.get(src)
r.raise_for_status()
return r.content
def _submit_txt2img(prompt: str, width: int, height: int, steps: int, seed: int) -> str:
wf = copy.deepcopy(_BASE_TXT2IMG)
wf["4"]["inputs"]["text"] = prompt
wf["6"]["inputs"]["width"] = width
wf["6"]["inputs"]["height"] = height
wf["8"]["inputs"]["steps"] = steps
wf["8"]["inputs"]["seed"] = seed
with httpx.Client(timeout=30) as c:
r = c.post(f"{COMFY}/prompt", json={"prompt": wf})
r.raise_for_status()
return r.json()["prompt_id"]
def _submit_img2img(prompt: str, img_bytes: bytes, strength: float, steps: int, seed: int) -> str:
fname = _upload_image(img_bytes, f"i2i_{uuid.uuid4().hex[:6]}.png")
wf = copy.deepcopy(_BASE_IMG2IMG)
wf["4"]["inputs"]["text"] = prompt
wf["11"]["inputs"]["image"] = fname
wf["8"]["inputs"]["steps"] = steps
wf["8"]["inputs"]["seed"] = seed
wf["8"]["inputs"]["denoise"] = max(0.05, min(1.0, strength))
with httpx.Client(timeout=30) as c:
r = c.post(f"{COMFY}/prompt", json={"prompt": wf})
r.raise_for_status()
return r.json()["prompt_id"]
# ---------------------------------------------------------------------------
# MCP tools
# ---------------------------------------------------------------------------
def _wait_for_job(job_id: str) -> list:
"""Block until the job is done/error/cancelled, or GEN_DEADLINE elapses.
Returns an MCP content list (text + optional Image).
"""
deadline = time.time() + GEN_DEADLINE
while time.time() < deadline:
with _lock:
job = dict(_jobs.get(job_id, {}))
elapsed = int(time.time() - job.get("submitted_at", time.time()))
if job.get("status") == "done":
url_line = f"\nS3: {job['s3_url']}" if job.get("s3_url") else ""
return [
f"**{job_id}** — done in {elapsed}s\nPrompt: {job['prompt'][:120]}{url_line}",
Image(data=job["result_png"], format="png"),
]
if job.get("status") == "error":
return [f"**{job_id}** — generation failed after {elapsed}s: {job.get('error','unknown')}"]
if job.get("status") == "cancelled":
return [f"**{job_id}** — cancelled after {elapsed}s"]
time.sleep(POLL_INTERVAL)
# Deadline exceeded — return job_id for manual follow-up
return [
f"**{job_id}** — still generating after {GEN_DEADLINE}s.\n"
f"Call `get_job_status([\"{job_id}\"])` to retrieve the image when ready."
]
@mcp.tool()
def generate_image(
prompt: str,
width: int = 1024,
height: int = 1024,
steps: int = 4,
seed: int = 0,
) -> list:
"""Generate an image from a text prompt. Waits for the result and returns it inline.
Generation takes 90-300 s; this tool blocks until the image is ready — no
follow-up call needed. The image is returned directly in this response.
FLUX renders legible in-image text well — put quoted text in the prompt.
4 steps is optimal for schnell. seed=0 picks a random seed.
"""
if seed == 0:
seed = random.randint(1, 2_147_483_647)
cpid = _submit_txt2img(prompt, width, height, steps, seed)
job_id = _register_job(cpid, prompt, "txt2img")
return _wait_for_job(job_id)
@mcp.tool()
def img2img(
prompt: str,
images: list[str],
strength: float = 0.75,
steps: int = 4,
seed: int = 0,
) -> list:
"""Apply a prompt to one or more existing images. Waits for all results and returns them inline.
Each element of `images` can be:
- URL: https://example.com/photo.jpg
- Base64 data URI: data:image/png;base64,...
- Job reference: job:<job_id> (chains from a previous generate_image or img2img output)
strength: how much to change the image. 0.1 = subtle variation, 0.75 = significant
change, 1.0 = treat as txt2img (ignore original content). Default 0.75.
All images are submitted at once and results returned inline when all are done.
seed=0 picks a different random seed for each image.
"""
if not images:
return ["No images provided."]
base_seed = seed if seed != 0 else random.randint(1, 2_147_483_647)
job_ids = []
out = []
for i, src in enumerate(images):
try:
img_bytes = _resolve_image(src)
except Exception as e:
out.append(f"Image {i + 1}: ERROR resolving source — {e}")
continue
try:
cpid = _submit_img2img(prompt, img_bytes, strength, steps, base_seed + i)
except Exception as e:
out.append(f"Image {i + 1}: ERROR submitting — {e}")
continue
job_id = _register_job(cpid, prompt, "img2img")
job_ids.append((i + 1, job_id))
# Wait for all jobs concurrently (poll shared registry)
deadline = time.time() + GEN_DEADLINE
pending = set(jid for _, jid in job_ids)
while pending and time.time() < deadline:
time.sleep(POLL_INTERVAL)
with _lock:
snapshot = {jid: dict(_jobs[jid]) for jid in pending if jid in _jobs}
pending = {jid for jid, j in snapshot.items() if j["status"] == "running"}
for idx, job_id in job_ids:
with _lock:
job = dict(_jobs.get(job_id, {}))
elapsed = int(time.time() - job.get("submitted_at", time.time()))
if job.get("status") == "done":
out.append(f"Image {idx} **{job_id}** — done in {elapsed}s")
out.append(Image(data=job["result_png"], format="png"))
elif job.get("status") == "error":
out.append(f"Image {idx} **{job_id}** — failed: {job.get('error','unknown')}")
else:
out.append(f"Image {idx} **{job_id}** — still running after {GEN_DEADLINE}s. Call get_job_status([\"{job_id}\"])")
return out
@mcp.tool()
def get_job_status(job_ids: list[str]) -> list:
"""Get the status of one or more jobs. Completed jobs are returned with the image inline.
For running jobs, call again in ~30 seconds.
Completed images can be referenced in img2img via 'job:<job_id>'.
"""
with _lock:
snapshot = {jid: dict(_jobs[jid]) for jid in job_ids if jid in _jobs}
out = []
for jid in job_ids:
job = snapshot.get(jid)
if not job:
out.append(f"Job **{jid}**: not found.")
continue
elapsed = int(time.time() - job["submitted_at"])
if job["status"] == "done":
url_line = f" — S3: {job['s3_url']}" if job.get("s3_url") else ""
out.append(f"Job **{jid}**: done in {elapsed}s — {job['kind']}{job['prompt'][:80]}{url_line}")
out.append(Image(data=job["result_png"], format="png"))
elif job["status"] == "error":
out.append(f"Job **{jid}**: ERROR ({elapsed}s) — {job.get('error', 'unknown')}")
elif job["status"] == "cancelled":
out.append(f"Job **{jid}**: cancelled ({elapsed}s)")
else:
out.append(f"Job **{jid}**: running ({elapsed}s) — {job['prompt'][:60]}")
return out
@mcp.tool()
def list_queue(limit: int = 10) -> str:
"""List the most recent jobs (all statuses) in the in-process registry.
Shows job_id, kind, status, elapsed time, and prompt snippet.
Use job_ids from this list with get_job_status() or as 'job:<id>' in img2img.
"""
with _lock:
jobs = sorted(_jobs.items(), key=lambda x: x[1]["submitted_at"], reverse=True)[:limit]
if not jobs:
return "No jobs in registry."
lines = ["Recent jobs (newest first):\n"]
for jid, j in jobs:
elapsed = int(time.time() - j["submitted_at"])
lines.append(f"- **{jid}** [{j['status']:9s}] {j['kind']:7s} {elapsed:5d}s {j['prompt'][:60]}")
return "\n".join(lines)
@mcp.tool()
def cancel_job(job_id: str) -> str:
"""Cancel a queued or running job.
Only call this after the user has explicitly confirmed. Cannot be undone.
"""
with _lock:
job = _jobs.get(job_id)
if not job:
return f"Job {job_id}: not found."
if job["status"] not in ("running",):
return f"Job {job_id}: already {job['status']} — nothing to cancel."
cpid = job["comfy_pid"]
actions = []
errors = []
with httpx.Client(timeout=10) as c:
try:
r = c.request("DELETE", f"{COMFY}/queue", json={"delete": [cpid]})
r.raise_for_status()
actions.append("dequeued")
except Exception as e:
errors.append(f"queue delete: {e}")
try:
r = c.post(f"{COMFY}/interrupt")
r.raise_for_status()
actions.append("interrupt sent")
except Exception as e:
errors.append(f"interrupt: {e}")
with _lock:
_jobs[job_id]["status"] = "cancelled"
msg = f"Job {job_id}: cancelled"
if actions:
msg += f" ({', '.join(actions)})"
if errors:
msg += f". Warnings: {', '.join(errors)}"
return msg
@mcp.tool()
def list_recent_images(n: int = 5) -> list:
"""Return the n most recently completed images from the registry.
Use this to review previous outputs or pick one as input for img2img
by passing 'job:<job_id>' in the images list.
"""
with _lock:
done = [(jid, j) for jid, j in _jobs.items() if j["status"] == "done" and j["result_png"]]
done.sort(key=lambda x: x[1]["submitted_at"], reverse=True)
done = done[:n]
if not done:
return ["No completed images in the current registry. Submit a job with generate_image() first."]
out = [f"**{len(done)} recent completed image(s)** — chain with img2img via 'job:<id>':\n"]
for jid, j in done:
elapsed = int(time.time() - j["submitted_at"])
url_line = f" — S3: {j['s3_url']}" if j.get("s3_url") else ""
out.append(f"**{jid}** ({j['kind']}, {elapsed}s ago): {j['prompt'][:80]}{url_line}")
out.append(Image(data=j["result_png"], format="png"))
return out
if __name__ == "__main__":
mcp.run(transport="streamable-http")