Files

488 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Anima Tool MCP Server
让 Cursor/Claude 等支持 MCP 的客户端可以直接调用图像生成,并原生显示图片。
启动方式:
python -m servers.mcp_server
或在 Cursor 配置中添加此 MCP Server。
"""
from __future__ import annotations
import asyncio
import base64
import json
import sys
from pathlib import Path
from typing import Any, Dict, Sequence
# 确保能 import 上层 executor
_PARENT = Path(__file__).resolve().parent.parent
if str(_PARENT) not in sys.path:
sys.path.insert(0, str(_PARENT))
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import (
Tool,
TextContent,
ImageContent,
CallToolResult,
)
from executor import AnimaExecutor, AnimaToolConfig
from executor import QwenExecutor
# 创建 MCP Server
server = Server("anima-tool")
# 全局 executor(懒加载)
_executor: AnimaExecutor | None = None
def get_executor() -> AnimaExecutor:
global _executor
if _executor is None:
_executor = AnimaExecutor(config=AnimaToolConfig())
return _executor
_qwen_executor: QwenExecutor | None = None
def get_qwen_executor() -> QwenExecutor:
global _qwen_executor
if _qwen_executor is None:
_qwen_executor = QwenExecutor(config=AnimaToolConfig())
return _qwen_executor
QWEN_TOOL_SCHEMA = {
"type": "object",
"properties": {
"edit_actions": {
"type": "array",
"description": "【修图必填】局部修改指令数组(如 ['把衬衫换成红色', '把脸替换为参考图'])。若为0参考文生图,必须留空!",
"items": {"type": "string"}
},
"character_and_action": {
"type": "string",
"description": "【生图必填】角色与主要动作的描述。局部修改时若不改动作请留空。"
},
"appearance_details": {
"type": "string",
"description": "【生图必填】角色的外貌与服装细节。如需固定身材特征(贫乳/娇小等),填入此处!局部修改时若不改外貌必须留空!"
},
"environment_and_lighting": {
"type": "string",
"description": "【生图必填】背景环境、光影描述。局部修改时若不改背景必须留空!"
},
"style_and_quality": {
"type": "string",
"description": "【全局选填】画质词、美颜滤镜或整体画风(如 '极致美颜,8k,电影级光影')。推荐使用。"
},
"preserve_constraints": {
"type": "string",
"description": "【修图核心锁】强制防崩坏指令!例如:'保持人物脸部特征和周围背景完全不变'。修图时强烈建议填写。"
},
"render_text": {
"type": "string",
"description": "【选填】需要 Qwen 在画面中生成的精准文字(如 '阿里巴巴')。不要自己加引号,后台会自动加。"
},
"base_image": {
"type": "string",
"description": "【选填】底图/原图的本地绝对路径。⚠️受 MCP 限制必须是绝对路径!传入后将切换为图生图(修图)模式,并自动映射为提示词中的 Picture 1。"
},
"reference_images": {
"type": "array",
"description": "【选填】参考图本地绝对路径数组(最多2张)。⚠️必须是绝对路径!传入后将自动依次映射为提示词中的 Picture 2, Picture 3。",
"items": {"type": "string"}
},
"aspect_ratio": {
"type": "string",
"description": "【选填】仅限文生图或需要扩图时使用(如 '16:9')。有 base_image 时默认跟随原图比例。"
},
"seed": {"type": "integer"},
"repeat": {
"type": "integer",
"description": "可选:独立任务重复次数。每次都会有不同随机种子。默认 1。",
"default": 1, "minimum": 1, "maximum": 16,
},
},
"required": []
}
# Tool Schema(从 tool_schema_universal.json 简化)
TOOL_SCHEMA = {
"type": "object",
"properties": {
"prompt_hint": {
"type": "string",
"description": "可选:人类可读的简短需求摘要,仅用于日志回显。"
},
"aspect_ratio": {
"type": "string",
"description": "可选:长宽比,如 '16:9'、'9:16'、'1:1'。默认 1:1。",
"enum": ["21:9", "2:1", "16:9", "16:10", "5:3", "3:2", "4:3", "1:1", "3:4", "2:3", "3:5", "10:16", "9:16", "1:2", "9:21"]
},
"width": {"type": "integer", "description": "可选:宽度(像素),须为16倍数。若指定则覆盖 aspect_ratio。"},
"height": {"type": "integer", "description": "可选:高度(像素),须为16倍数。若指定则覆盖 aspect_ratio。"},
"quality_meta_year_safe": {
"type": "string",
"description": "必选:质量/年份/安全标签。必须包含 safe/sensitive/nsfw/explicit 之一。示例: 'masterpiece, best quality, year 2024, safe'"
},
"count": {
"type": "string",
"description": "必选:人数标签,如 '1girl'、'2girls'、'1boy'。"
},
"character": {"type": "string", "description": "可选:角色名(可含作品名括号),如 'hatsune miku' 或 'yunli (honkai star rail)'。"},
"series": {"type": "string", "description": "可选:作品/系列名,如 'vocaloid'。"},
"appearance": {"type": "string", "description": "可选:角色固定外观描述(发色、眼睛、身材等,不含服装)。"},
"artist": {
"type": "string",
"description": "必选:画师标签,必须以 @ 开头(如 @fkey)。多画师逗号分隔。若用户没指定画师,请根据风格推荐一位。"
},
"style": {"type": "string", "description": "可选:画风倾向或特定渲染风格。"},
"tags": {
"type": "string",
"description": "必选:核心 Danbooru 标签(逗号分隔)。建议包含动作、构图、服装、表情等。"
},
"nltags": {"type": "string", "description": "可选:自然语言补充(仅在 tag 难以描述时使用)。"},
"environment": {"type": "string", "description": "可选:环境与背景光影描述。"},
"neg": {
"type": "string",
"description": "必选:负面提示词。默认已包含通用反咒。建议加入与安全标签相反的约束。",
"default": "worst quality, low quality, score_1, score_2, score_3, blurry, bad hands, bad anatomy, text, watermark"
},
"steps": {"type": "integer", "description": "可选:步数,默认 25。", "default": 25},
"cfg": {"type": "number", "description": "可选:CFG,默认 4.5。", "default": 4.5},
"sampler_name": {"type": "string", "description": "可选:采样器,默认 er_sde。", "default": "er_sde"},
"seed": {"type": "integer", "description": "可选:随机种子。不填则每次生成都随机。"},
"repeat": {
"type": "integer",
"description": "可选:独立任务重复次数。每次都会有不同随机种子。默认 1。",
"default": 1, "minimum": 1, "maximum": 16,
},
"batch_size": {
"type": "integer",
"description": "可选:单任务内的 batch size。默认 1。",
"default": 1, "minimum": 1, "maximum": 4,
},
"loras": {
"type": "array",
"description": "可选:LoRA 列表。name 须匹配 list_anima_models(model_type=loras) 返回值。",
"items": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "LoRA 文件名(含子目录)"},
"weight": {"type": "number", "default": 1.0}
},
"required": ["name"]
}
},
"unet_name": {
"type": "string",
"description": "高级:UNET 模型文件名,默认 Anima。用 list_anima_models(model_type=diffusion_models) 查询可用模型。",
},
"clip_name": {
"type": "string",
"description": "高级:文本编码器文件名,默认 Qwen3。用 list_anima_models(model_type=text_encoders) 查询。",
},
"vae_name": {
"type": "string",
"description": "高级:VAE 文件名,默认 Anima VAE。用 list_anima_models(model_type=vae) 查询。",
},
},
"required": ["quality_meta_year_safe", "count", "artist", "tags", "neg"]
}
LIST_MODELS_SCHEMA = {
"type": "object",
"properties": {
"model_type": {
"type": "string",
"enum": ["loras", "diffusion_models", "vae", "text_encoders"],
"description": "模型类型。loras 仅返回有 .json sidecar 元数据的 LoRA。",
}
},
"required": ["model_type"],
}
LIST_HISTORY_SCHEMA = {
"type": "object",
"properties": {
"limit": {
"type": "integer",
"description": "返回最近几条历史记录(默认 5)",
"default": 5, "minimum": 1, "maximum": 50,
},
},
}
# reroll schema:source 必填 + generate 的所有参数可作为【可选覆盖项】
# 关键:需要把原 generate 中"必选"的描述改为"可选覆盖",否则 AI 会自动填入
def _build_reroll_override_props() -> dict:
"""复制 generate schema 的属性,但将描述中的'必选'改为'可选覆盖'。"""
import copy
props = copy.deepcopy(TOOL_SCHEMA["properties"])
for _k, _v in props.items():
desc = _v.get("description", "")
if desc.startswith("必选:"):
_v["description"] = "可选覆盖(不提供则沿用历史记录):" + desc[3:]
return props
_REROLL_OVERRIDE_PROPS = _build_reroll_override_props()
REROLL_SCHEMA = {
"type": "object",
"properties": {
"source": {
"type": "string",
"description": "必选:要 reroll 的基础记录。'last' 表示最近一条,或使用历史 ID(如 '12')。",
},
**_REROLL_OVERRIDE_PROPS,
},
"required": ["source"],
}
@server.list_tools()
async def list_tools() -> list[Tool]:
"""列出可用工具"""
return [
Tool(
name="generate_anima_image",
description=(
"使用 Anima 模型生成二次元/插画图片。画师必须以 @ 开头(如 @fkey)。"
"必须明确安全标签(safe/sensitive/nsfw/explicit)。"
"支持 repeat 参数一次提交多个独立生成任务(默认方式),"
"也支持 batch_size 参数在单任务内生成多张。"
),
inputSchema=TOOL_SCHEMA,
),
Tool(
name="list_anima_models",
description=(
"查询 ComfyUI 当前可用的模型文件列表(loras/diffusion_models/vae/text_encoders)。"
"注意:当 model_type=loras 时,强制只返回存在同名 .json sidecar 元数据文件的 LoRA。"
),
inputSchema=LIST_MODELS_SCHEMA,
),
Tool(
name="list_anima_history",
description=(
"查看最近的图片生成历史记录。"
"返回每条记录的 ID、时间、画师、标签、种子等摘要信息。"
"用于在 reroll 之前确认要引用哪条历史。"
),
inputSchema=LIST_HISTORY_SCHEMA,
),
Tool(
name="reroll_anima_image",
description=(
"基于历史记录【覆盖】重新生成。source 以外的所有参数均为【可选覆盖项】。"
"如果不提供覆盖参数,则完全沿用历史记录(seed 默认除外)。"
"seed 默认自动随机(出不同画面),也可手动指定保持一致。"
"支持 repeat 参数一次提交多个独立任务。"
),
inputSchema=REROLL_SCHEMA,
),
Tool(
name="generate_qwen_image",
description=(
"使用 QwenEdit 强大的自然语言和视觉理解引擎,支持“0参考文生图”以及“极致约束的多重图片编辑”。\n"
"**【视觉映射规则(极度重要)】**:\n"
"- 传入了 `base_image` 后,底层引擎会自动将其标记为图 1。在你的 `edit_actions` 文本中,**必须使用 'Picture 1' 来代指原图**。\n"
"- 传入了 `reference_images` 后,它们会被依次标记为图 2、图 3。文本中,**必须使用 'Picture 2' 或 'Picture 3' 来代指参考图**。\n"
"**【智能填表哲学】**:\n"
"- **纯生图**:填满 character, appearance, environment。edit_actions 留空。\n"
"- **局部修图**:把火力集中在 edit_actions 数组和 preserve_constraints 锁上。不需要改的外貌或环境坚决留空!\n"
"- **身材/特征锚定**:修图时如果不希望特定身材(如贫乳)被归一化,请填入 appearance_details 强制锚定。"
),
inputSchema=QWEN_TOOL_SCHEMA,
),
]
async def _generate_with_repeat(
executor: "AnimaExecutor",
prompt_json: Dict[str, Any],
) -> list[TextContent | ImageContent]:
"""执行生成(支持 repeat 多次独立 queue 提交),返回 MCP 内容列表。"""
from copy import deepcopy
repeat = max(1, int(prompt_json.pop("repeat", 1) or 1))
# batch_size 留在 prompt_json 中,由 executor._inject() 处理
all_contents: list[TextContent | ImageContent] = []
history_ids: list[int] = []
for i in range(repeat):
run_params = deepcopy(prompt_json)
# 每次 repeat 都使用新随机 seed(除非用户显式指定了 seed)
if "seed" not in prompt_json or prompt_json.get("seed") is None:
run_params.pop("seed", None)
result = await asyncio.to_thread(executor.generate, run_params)
if not result.get("success"):
all_contents.append(TextContent(type="text", text=f"第 {i+1}/{repeat} 次生成失败: {result}"))
continue
if result.get("history_id"):
history_ids.append(result["history_id"])
for img in result.get("images", []):
saved_path = img.get("saved_path") or img.get("file_path")
if saved_path:
all_contents.append(TextContent(type="text", text=f"图片已保存到本地:{saved_path}"))
if img.get("base64") and img.get("mime_type"):
all_contents.append(
ImageContent(
type="image",
data=img["base64"],
mimeType=img["mime_type"],
)
)
if not all_contents:
all_contents.append(TextContent(type="text", text="生成完成,但没有产出图片。"))
# 追加历史 ID 提示,让 AI 自然知道可以 reroll
if history_ids:
ids_str = ", ".join(f"#{hid}" for hid in history_ids)
hint = f"已保存为历史记录 {ids_str}。可用 reroll_anima_image(source=\"{history_ids[-1]}\") 或 reroll_anima_image(source=\"last\") 重新生成。"
all_contents.append(TextContent(type="text", text=hint))
return all_contents
async def _generate_qwen_with_repeat(
executor: "QwenExecutor",
prompt_json: Dict[str, Any],
) -> list[TextContent | ImageContent]:
from copy import deepcopy
repeat = max(1, int(prompt_json.pop("repeat", 1) or 1))
all_contents: list[TextContent | ImageContent] = []
history_ids: list[int] = []
for i in range(repeat):
run_params = deepcopy(prompt_json)
if "seed" not in prompt_json or prompt_json.get("seed") is None:
run_params.pop("seed", None)
result = await asyncio.to_thread(executor.generate, run_params)
if not result.get("success"):
all_contents.append(TextContent(type="text", text=f"第 {i+1}/{repeat} 次 Qwen 生成失败: {result}"))
continue
if result.get("history_id"):
history_ids.append(result["history_id"])
instruction_sent = result.get("instruction_sent", "")
all_contents.append(TextContent(type="text", text=f"**Qwen 最终指令 ({result.get('width', 0)}x{result.get('height', 0)})**:\n> {instruction_sent}"))
for img in result.get("images", []):
saved_path = img.get("saved_path") or img.get("file_path")
if saved_path:
all_contents.append(TextContent(type="text", text=f"图片已保存到本地:{saved_path}"))
if img.get("base64") and img.get("mime_type"):
all_contents.append(
ImageContent(
type="image",
data=img["base64"],
mimeType=img["mime_type"],
)
)
if not all_contents:
all_contents.append(TextContent(type="text", text="生成完成,但没有产出图片。"))
if history_ids:
ids_str = ", ".join(f"#{hid}" for hid in history_ids)
hint = f"已保存为 Qwen 历史记录 {ids_str}。"
all_contents.append(TextContent(type="text", text=hint))
return all_contents
@server.call_tool()
async def call_tool(name: str, arguments: Dict[str, Any]) -> Sequence[TextContent | ImageContent]:
"""调用工具"""
try:
executor = get_executor()
args = dict(arguments or {})
# ---- list_anima_models ----
if name == "list_anima_models":
model_type = str(args.get("model_type") or "").strip()
if not model_type:
return [TextContent(type="text", text="参数错误:model_type 不能为空")]
result = await asyncio.to_thread(executor.list_models, model_type)
return [TextContent(type="text", text=json.dumps(result, ensure_ascii=False, indent=2))]
# ---- list_anima_history ----
if name == "list_anima_history":
executor = get_executor()
limit = int(args.get("limit") or 5)
records = executor.history.list_recent(limit)
if not records:
return [TextContent(type="text", text="暂无生成历史。")]
lines = [r.summary() for r in records]
return [TextContent(type="text", text="\n".join(lines))]
# ---- reroll_anima_image ----
if name == "reroll_anima_image":
executor = get_executor()
source = str(args.pop("source", "")).strip()
if not source:
return [TextContent(type="text", text="参数错误:source 不能为空(使用 'last' 或历史 ID)")]
record = executor.history.get(source)
if record is None:
return [TextContent(type="text", text=f"未找到历史记录:{source}。请先使用 list_anima_history 查看可用记录。")]
# 深拷贝原始参数,用覆盖项更新
from copy import deepcopy
merged = deepcopy(record.params)
overrides = {k: v for k, v in args.items() if v is not None}
merged.update(overrides)
# seed 默认行为:未显式指定则自动随机(删掉原 seed)
if "seed" not in args or args.get("seed") is None:
merged.pop("seed", None)
return await _generate_with_repeat(executor, merged)
# ---- generate_anima_image ----
if name == "generate_anima_image":
executor = get_executor()
return await _generate_with_repeat(executor, args)
# ---- generate_qwen_image ----
if name == "generate_qwen_image":
qwen_executor = get_qwen_executor()
return await _generate_qwen_with_repeat(qwen_executor, args)
return [TextContent(type="text", text=f"未知工具: {name}")]
except Exception as e:
return [TextContent(type="text", text=f"错误: {str(e)}")]
async def main():
"""启动 MCP Server(stdio 模式)"""
async with stdio_server() as (read_stream, write_stream):
await server.run(read_stream, write_stream, server.create_initialization_options())
if __name__ == "__main__":
asyncio.run(main())