049b3c0901
- client.py: GraphQL-backed list_products() and get_last_version()
(AYON >=1.15 compat, replaces broken REST endpoints)
- client.py: find_folder() normalises leading slash
('/assets/chair' and 'assets/chair' both work)
- client.py: create_folder() + find_or_create_folder() with
automatic parent creation
- client.py: find_product_by_name() for duplicate detection
- server.py: ayon_create_folder tool (creates folder tree)
- server.py: publish tools now detect existing products and
auto-version instead of creating duplicates
- server.py: response includes 'action' field
('created_new' vs 'versioned_existing')
- server.py: model_type and folder_type enum validation
- server.py: ayon_get_product uses GraphQL for read-back
364 lines
11 KiB
Python
364 lines
11 KiB
Python
"""AYON MCP Server — main entry point.
|
|
|
|
Usage:
|
|
AYON_URL=http://172.18.0.2:5000 \\
|
|
AYON_KEY=sk-... \\
|
|
ayon-mcp
|
|
|
|
Supports stdio (Claude Desktop, Cursor) and HTTP (Docker, Hermes) transport.
|
|
Set AYON_TRANSPORT=http for HTTP mode.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
from typing import Optional
|
|
|
|
from mcp.server.fastmcp import FastMCP
|
|
from mcp.server.transport_security import TransportSecuritySettings
|
|
|
|
from .client import AyonClient
|
|
|
|
# ── Bootstrap ────────────────────────────────────────────
|
|
|
|
app = FastMCP(
|
|
"ayon-publish",
|
|
transport_security=TransportSecuritySettings(
|
|
enable_dns_rebinding_protection=False
|
|
),
|
|
)
|
|
client = AyonClient()
|
|
|
|
|
|
def _ok(**kwargs) -> str:
|
|
return json.dumps(dict(status="ok", **kwargs), indent=2, ensure_ascii=False)
|
|
|
|
|
|
def _err(msg: str) -> str:
|
|
return json.dumps({"error": msg}, indent=2)
|
|
|
|
|
|
# ── Discovery Tools ──────────────────────────────────────
|
|
|
|
@app.tool()
|
|
async def ayon_list_projects() -> str:
|
|
"""List all available AYON projects."""
|
|
try:
|
|
projects = client.list_projects()
|
|
summary = [{"name": p["name"], "code": p.get("code")} for p in projects]
|
|
return json.dumps(summary, indent=2)
|
|
except Exception as e:
|
|
return _err(str(e))
|
|
|
|
|
|
@app.tool()
|
|
async def ayon_list_folders(project: str) -> str:
|
|
"""List folders in an AYON project.
|
|
|
|
Args:
|
|
project: Project name (e.g. 'MyProject')
|
|
"""
|
|
try:
|
|
folders = client.list_folders(project)
|
|
summary = [
|
|
{"path": f.get("path"), "type": f.get("folderType"), "id": f.get("id")}
|
|
for f in folders
|
|
]
|
|
return json.dumps(summary, indent=2)
|
|
except Exception as e:
|
|
return _err(str(e))
|
|
|
|
|
|
@app.tool()
|
|
async def ayon_list_products(project: str, folder_path: str) -> str:
|
|
"""List published products in a folder.
|
|
|
|
Uses GraphQL for AYON >=1.15 compatibility.
|
|
Accepts paths with or without leading slash.
|
|
|
|
Args:
|
|
project: Project name
|
|
folder_path: Folder path (e.g. '/assets/chair' or 'assets/chair')
|
|
"""
|
|
try:
|
|
folder = client.find_folder(project, folder_path)
|
|
if not folder:
|
|
return _err(f"Folder '{folder_path}' not found in '{project}'")
|
|
products = client.list_products(project, folder["id"])
|
|
summary = [
|
|
{"name": p["name"], "type": p.get("productType"), "id": p["id"]}
|
|
for p in products
|
|
]
|
|
return json.dumps(summary, indent=2)
|
|
except Exception as e:
|
|
return _err(str(e))
|
|
|
|
|
|
@app.tool()
|
|
async def ayon_get_product(project: str, folder_path: str, product_name: str) -> str:
|
|
"""Get details about a specific product, including latest version.
|
|
|
|
Uses GraphQL for AYON >=1.15 compatibility.
|
|
|
|
Args:
|
|
project: Project name
|
|
folder_path: Folder path
|
|
product_name: Product name to find
|
|
"""
|
|
try:
|
|
folder = client.find_folder(project, folder_path)
|
|
if not folder:
|
|
return _err(f"Folder '{folder_path}' not found")
|
|
product = client.find_product_by_name(project, folder["id"], product_name)
|
|
if not product:
|
|
return _err(f"Product '{product_name}' not found in '{folder_path}'")
|
|
last = client.get_last_version(project, product["id"])
|
|
return json.dumps({
|
|
"name": product["name"],
|
|
"type": product.get("productType"),
|
|
"id": product["id"],
|
|
"latest_version": last["version"] if last else None,
|
|
}, indent=2)
|
|
except Exception as e:
|
|
return _err(str(e))
|
|
|
|
|
|
# ── Folder Management ────────────────────────────────────
|
|
|
|
@app.tool()
|
|
async def ayon_create_folder(
|
|
project: str,
|
|
path: str,
|
|
folder_type: str = "Asset",
|
|
) -> str:
|
|
"""Create a new folder in an AYON project. Creates parent folders as needed.
|
|
|
|
Args:
|
|
project: Project name (e.g. 'lumenfjord')
|
|
path: Folder path, slash-separated (e.g. 'assets/test/my_folder')
|
|
folder_type: AYON folder type: 'Asset', 'Folder', 'Shot', 'Sequence',
|
|
'Episode', 'Library' (default: 'Asset')
|
|
"""
|
|
VALID_TYPES = {"Folder", "Library", "Asset", "Episode", "Sequence", "Shot"}
|
|
if folder_type not in VALID_TYPES:
|
|
return _err(f"Invalid folder type '{folder_type}'. Must be one of: {', '.join(sorted(VALID_TYPES))}")
|
|
|
|
try:
|
|
folder = client.find_or_create_folder(project, path, folder_type)
|
|
return json.dumps({
|
|
"name": folder["name"],
|
|
"path": folder.get("path"),
|
|
"id": folder["id"],
|
|
"created": True,
|
|
}, indent=2)
|
|
except Exception as e:
|
|
return _err(str(e))
|
|
|
|
|
|
# ── Publishing Tools ─────────────────────────────────────
|
|
|
|
def _publish_product(
|
|
project: str,
|
|
folder_path: str,
|
|
product_name: str,
|
|
product_type: str,
|
|
representation_name: str,
|
|
files_list: list[dict],
|
|
variant: str = "",
|
|
) -> str:
|
|
"""Core publish logic — creates product or auto-versions existing one.
|
|
|
|
Returns a JSON string with status and details.
|
|
The caller decides if this is modelMain or imageMain.
|
|
"""
|
|
folder = client.find_folder(project, folder_path)
|
|
if not folder:
|
|
return _err(f"Folder '{folder_path}' not found in '{project}'")
|
|
|
|
full_name = product_name
|
|
if variant:
|
|
full_name = f"{product_name}_{variant}"
|
|
|
|
# Check if product already exists
|
|
existing = client.find_product_by_name(project, folder["id"], full_name)
|
|
|
|
if existing:
|
|
# Product exists — auto-version
|
|
last = client.get_last_version(project, existing["id"])
|
|
version_num = (last["version"] + 1) if last else 1
|
|
|
|
version = client.create_version(
|
|
project=project,
|
|
product_id=existing["id"],
|
|
version=version_num,
|
|
)
|
|
|
|
rep = client.create_representation(
|
|
project=project,
|
|
version_id=version["id"],
|
|
name=representation_name,
|
|
files=files_list,
|
|
)
|
|
|
|
return _ok(
|
|
action="versioned_existing",
|
|
product=full_name,
|
|
type=product_type,
|
|
version=version_num,
|
|
existing_product=True,
|
|
product_id=existing["id"],
|
|
version_id=version["id"],
|
|
folder=folder_path,
|
|
files=[f["name"] for f in files_list],
|
|
note=f"Product '{full_name}' already existed. Created version {version_num}.",
|
|
)
|
|
else:
|
|
# New product
|
|
product = client.create_product(
|
|
project=project,
|
|
name=full_name,
|
|
product_type=product_type,
|
|
folder_id=folder["id"],
|
|
)
|
|
|
|
last = client.get_last_version(project, product["id"])
|
|
version_num = (last["version"] + 1) if last else 1
|
|
|
|
version = client.create_version(
|
|
project=project,
|
|
product_id=product["id"],
|
|
version=version_num,
|
|
)
|
|
|
|
rep = client.create_representation(
|
|
project=project,
|
|
version_id=version["id"],
|
|
name=representation_name,
|
|
files=files_list,
|
|
)
|
|
|
|
return _ok(
|
|
action="created_new",
|
|
product=full_name,
|
|
type=product_type,
|
|
version=version_num,
|
|
existing_product=False,
|
|
product_id=product["id"],
|
|
version_id=version["id"],
|
|
folder=folder_path,
|
|
files=[f["name"] for f in files_list],
|
|
note=f"New product '{full_name}' created with version {version_num}.",
|
|
)
|
|
|
|
|
|
@app.tool()
|
|
async def ayon_publish_texture_set(
|
|
project: str,
|
|
folder_path: str,
|
|
product_name: str,
|
|
variant: str = "",
|
|
textures: str = "[]",
|
|
) -> str:
|
|
"""Publish a texture set (multiple maps) as a single product.
|
|
|
|
If a product with the same name already exists, a new VERSION is created
|
|
instead of a duplicate product. The response indicates which happened.
|
|
|
|
Args:
|
|
project: Project name (e.g. 'DemoProject')
|
|
folder_path: Folder path (e.g. '/assets/chair' or 'assets/chair')
|
|
product_name: Base product name (e.g. 'texture_chair')
|
|
variant: Optional variant (e.g. 'brown_leather')
|
|
textures: JSON array of texture info:
|
|
[{"name": "BaseColor.png", "size": 1048576}, ...]
|
|
"""
|
|
try:
|
|
textures_list = json.loads(textures)
|
|
return _publish_product(
|
|
project=project,
|
|
folder_path=folder_path,
|
|
product_name=product_name,
|
|
product_type="imageMain",
|
|
representation_name="texture_set",
|
|
files_list=textures_list,
|
|
variant=variant,
|
|
)
|
|
except Exception as e:
|
|
return _err(str(e))
|
|
|
|
|
|
@app.tool()
|
|
async def ayon_publish_model(
|
|
project: str,
|
|
folder_path: str,
|
|
product_name: str,
|
|
model_type: str = "abc",
|
|
variant: str = "",
|
|
files: str = "[]",
|
|
) -> str:
|
|
"""Publish a 3D model (ABC, USD, FBX, etc.).
|
|
|
|
If a product with the same name already exists, a new VERSION is created
|
|
instead of a duplicate product. The response indicates which happened
|
|
via the 'action' field ('created_new' or 'versioned_existing').
|
|
|
|
Args:
|
|
project: Project name
|
|
folder_path: Folder path (e.g. '/assets/chair' or 'assets/chair')
|
|
product_name: Product name (e.g. 'modelMain')
|
|
model_type: File format: 'abc', 'usd', 'fbx', 'obj', 'blend'
|
|
variant: Optional variant (e.g. 'highpoly')
|
|
files: JSON array of file info:
|
|
[{"name": "chair.abc", "size": 2048000}]
|
|
"""
|
|
VALID_FORMATS = {"abc", "usd", "fbx", "obj", "blend"}
|
|
if model_type not in VALID_FORMATS:
|
|
return _err(f"Invalid model type '{model_type}'. Must be one of: {', '.join(sorted(VALID_FORMATS))}")
|
|
|
|
try:
|
|
files_list = json.loads(files)
|
|
return _publish_product(
|
|
project=project,
|
|
folder_path=folder_path,
|
|
product_name=product_name,
|
|
product_type="modelMain",
|
|
representation_name=model_type,
|
|
files_list=files_list,
|
|
variant=variant,
|
|
)
|
|
except Exception as e:
|
|
return _err(str(e))
|
|
|
|
|
|
# ── Health ───────────────────────────────────────────────
|
|
|
|
|
|
@app.custom_route("/health", methods=["GET"])
|
|
async def health(request): # noqa: ARG001
|
|
from starlette.responses import JSONResponse
|
|
|
|
return JSONResponse({"status": "ok"})
|
|
|
|
|
|
# ── Entry Point ──────────────────────────────────────────
|
|
|
|
def main():
|
|
if not os.environ.get("AYON_URL"):
|
|
print("ERROR: AYON_URL not set", file=sys.stderr)
|
|
sys.exit(1)
|
|
if not os.environ.get("AYON_KEY"):
|
|
print("ERROR: AYON_KEY not set", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
transport = os.environ.get("AYON_TRANSPORT", "stdio")
|
|
if transport == "http":
|
|
import uvicorn
|
|
port = int(os.environ.get("PORT", "8000"))
|
|
uvicorn.run(app.streamable_http_app(), host="0.0.0.0", port=port, log_level="info")
|
|
else:
|
|
app.run(transport="stdio")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|