Add inference-image MCP server (accessible image generation) + proper favicon sizes
- mcp/image_server.py: generate_image tool via the co-op gateway; per-member key injected through customUserVars (IC_API_KEY) -> env; verified standalone (handshake, tools/list, real 1024x1024 + 1536x1024 images, error paths) - librechat.yaml: register inference-image with startup:false (per-user credentials), 120s timeout - assets/: real favicon sizes (16/32/192), opaque apple-touch 180, padded maskable 512
This commit is contained in:
1 parent
5169915641
commit
5e214e4d7e
8 files changed
+249
-14
No files matched your search
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Inference Cooperative image-generation MCP server (stdio, stdlib-only).
|
||||
|
||||
Exposes a `generate_image` tool that calls the co-op gateway
|
||||
(`regolo/qwen-image` via POST /v1/images/generations). Each member's own
|
||||
co-op API key is injected per-user by LibreChat as the IC_API_KEY env var
|
||||
(`customUserVars` + `env` in librechat.yaml), so usage bills to the member's
|
||||
own LiteLLM team budget — the same model as the chat endpoint.
|
||||
|
||||
Design notes (mirrors mcp/server.py, the docs server):
|
||||
- stdio transport: newline-delimited JSON-RPC 2.0; logging to stderr only.
|
||||
- No third-party dependencies. The image is returned inline as MCP image
|
||||
content (base64 PNG), which LibreChat renders directly in the chat.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
GATEWAY_IMAGES_URL = "https://gateway.inference.coop/v1/images/generations"
|
||||
MODEL = "regolo/qwen-image"
|
||||
REQUEST_TIMEOUT = 90
|
||||
ALLOWED_SIZES = ("1024x1024", "1536x1024", "1024x1536")
|
||||
DEFAULT_SIZE = "1024x1024"
|
||||
DASHBOARD_URL = "https://dashboard.inference.coop"
|
||||
|
||||
SERVER_NAME = "inference-cooperative-image"
|
||||
SERVER_VERSION = "1.0.0"
|
||||
|
||||
|
||||
def log(msg):
|
||||
sys.stderr.write(f"[inference-image-mcp] {msg}\n")
|
||||
sys.stderr.flush()
|
||||
|
||||
|
||||
def tool_generate_image(args):
|
||||
key = (os.environ.get("IC_API_KEY") or "").strip()
|
||||
if not key:
|
||||
return {
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": (
|
||||
"No Inference Co-op API key configured yet. Open the MCP panel "
|
||||
f"(inference-image) and save your key — create one at {DASHBOARD_URL}. "
|
||||
"Your key is only visible to you, and image generation bills to your "
|
||||
"own co-op balance."
|
||||
),
|
||||
}],
|
||||
"isError": True,
|
||||
}
|
||||
|
||||
prompt = str(args.get("prompt") or "").strip()
|
||||
if not prompt:
|
||||
return {"content": [{"type": "text", "text": "Provide a prompt for the image."}]}
|
||||
|
||||
size = str(args.get("size") or DEFAULT_SIZE).strip()
|
||||
if size not in ALLOWED_SIZES:
|
||||
return {
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": f"Unsupported size '{size}'. Use one of: {', '.join(ALLOWED_SIZES)}.",
|
||||
}],
|
||||
"isError": True,
|
||||
}
|
||||
|
||||
body = json.dumps({"model": MODEL, "prompt": prompt, "n": 1, "size": size}).encode()
|
||||
req = urllib.request.Request(
|
||||
GATEWAY_IMAGES_URL,
|
||||
data=body,
|
||||
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", "replace")[:400]
|
||||
log(f"gateway HTTP {exc.code}: {detail}")
|
||||
hint = "Check that your co-op API key is valid and has balance."
|
||||
if exc.code == 401:
|
||||
hint = f"Your key was rejected — re-save it in the MCP panel (create one at {DASHBOARD_URL})."
|
||||
elif exc.code == 429:
|
||||
hint = "Budget or rate limit reached on your key."
|
||||
return {
|
||||
"content": [{"type": "text", "text": f"Image generation failed (HTTP {exc.code}). {hint}"}],
|
||||
"isError": True,
|
||||
}
|
||||
except Exception as exc: # noqa: BLE001 - report transport errors in-band
|
||||
log(f"gateway error: {exc}")
|
||||
return {
|
||||
"content": [{"type": "text", "text": f"Image generation failed: {exc}"}],
|
||||
"isError": True,
|
||||
}
|
||||
|
||||
items = (data or {}).get("data") or []
|
||||
if not items:
|
||||
return {"content": [{"type": "text", "text": "The gateway returned no image."}], "isError": True}
|
||||
|
||||
item = items[0]
|
||||
content = []
|
||||
if item.get("b64_json"):
|
||||
content.append({"type": "image", "data": item["b64_json"], "mimeType": "image/png"})
|
||||
notes = [f"Generated with {MODEL} ({size}) — billed to your co-op key."]
|
||||
if item.get("revised_prompt"):
|
||||
notes.append(f"Prompt: {item['revised_prompt']}")
|
||||
if not item.get("b64_json") and item.get("url"):
|
||||
notes.append(f"Image URL: {item['url']}")
|
||||
content.append({"type": "text", "text": " ".join(notes)})
|
||||
return {"content": content}
|
||||
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "generate_image",
|
||||
"description": (
|
||||
"Generate an image from a text prompt (regolo/qwen-image via the Inference "
|
||||
"Co-op gateway, EU renewable-powered). Costs roughly 1-2 cents per image, "
|
||||
"billed to your own co-op API key."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prompt": {"type": "string", "description": "What to draw"},
|
||||
"size": {
|
||||
"type": "string",
|
||||
"enum": list(ALLOWED_SIZES),
|
||||
"description": "Image size (default 1024x1024)",
|
||||
},
|
||||
},
|
||||
"required": ["prompt"],
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
TOOLS_HANDLERS = {"generate_image": tool_generate_image}
|
||||
|
||||
|
||||
def handle(msg):
|
||||
method = msg.get("method")
|
||||
msg_id = msg.get("id")
|
||||
|
||||
if method == "initialize":
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"result": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION},
|
||||
},
|
||||
}
|
||||
if method == "ping":
|
||||
return {"jsonrpc": "2.0", "id": msg_id, "result": {}}
|
||||
if method == "tools/list":
|
||||
return {"jsonrpc": "2.0", "id": msg_id, "result": {"tools": TOOLS}}
|
||||
if method == "tools/call":
|
||||
params = msg.get("params") or {}
|
||||
name = params.get("name")
|
||||
handler = TOOLS_HANDLERS.get(name)
|
||||
if not handler:
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"error": {"code": -32602, "message": f"Unknown tool: {name}"},
|
||||
}
|
||||
try:
|
||||
result = handler(params.get("arguments") or {})
|
||||
return {"jsonrpc": "2.0", "id": msg_id, "result": result}
|
||||
except Exception as exc: # noqa: BLE001 - report tool errors in-band
|
||||
log(f"tool error: {exc}")
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"result": {"content": [{"type": "text", "text": f"Tool failed: {exc}"}], "isError": True},
|
||||
}
|
||||
if method and method.startswith("notifications/"):
|
||||
return None
|
||||
if msg_id is not None:
|
||||
return {"jsonrpc": "2.0", "id": msg_id, "error": {"code": -32601, "message": "Method not found"}}
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
log("starting (stdio)")
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
except json.JSONDecodeError as exc:
|
||||
log(f"bad json: {exc}")
|
||||
continue
|
||||
try:
|
||||
resp = handle(msg)
|
||||
except Exception as exc: # noqa: BLE001 - never crash the protocol loop
|
||||
log(f"handler crash: {exc}")
|
||||
if msg.get("id") is not None:
|
||||
resp = {"jsonrpc": "2.0", "id": msg["id"], "error": {"code": -32603, "message": "Internal error"}}
|
||||
else:
|
||||
resp = None
|
||||
if resp is not None:
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user