Author SHA1 Message Date
Niklas d471245c51 Add OMP_SETUP_VERIFIED.md 2026-09-03 14:23:28 +02:00
Niklas 3f38c92de6 OMP+Qwen3.8 Setup-Test 2026-09-03 14:23:04 +02:00
9 changed files with 2 additions and 690 deletions
-7
View File
@@ -1,7 +0,0 @@
venv/
__pycache__/
*.pyc
.eggs/
dist/
*.egg-info/
.git/
-6
View File
@@ -1,6 +0,0 @@
venv/
__pycache__/
*.pyc
.eggs/
dist/
*.egg-info/
-13
View File
@@ -1,13 +0,0 @@
FROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml .
RUN pip install --no-cache-dir .
COPY src/ src/
ENV AYON_TRANSPORT=http
EXPOSE 8000
CMD ["ayon-mcp"]
+1
View File
@@ -0,0 +1 @@
OMP + Qwen3.8 + Gitea verified 2026-09-03
+1 -195
View File
@@ -1,195 +1 @@
# AYON MCP Server # OMP + Qwen3.8 Test
**AI agents publish CGI assets to AYON via Model Context Protocol.**
`ayon-mcp` wraps AYON's REST API into six MCP tools — any MCP-compatible agent
can browse projects, list folders, and publish texture sets or 3D models with
simple tool calls. No raw HTTP, no manual JSON.
Works with **Claude Desktop**, **Cursor**, **Hermes Agent**, and any other
MCP client (stdio or HTTP transport).
## Tools
| Tool | What it does |
|------|-------------|
| `ayon_list_projects` | List all AYON projects |
| `ayon_list_folders` | List folders in a project |
| `ayon_list_products` | List published products in a folder |
| `ayon_get_product` | Get product details + latest version |
| `ayon_publish_texture_set` | Publish multiple texture maps as one product |
| `ayon_publish_model` | Publish a 3D model (ABC, USD, FBX, ...) |
## Quick Start
### 1. Install
```bash
pip install git+https://git.niklashmotion.art/Hermes/ayon-mcp.git
```
### 2. Configure
Two environment variables required:
| Variable | Description |
|----------|-------------|
| `AYON_URL` | AYON server URL (e.g. `https://ayon.niklashmotion.art`) |
| `AYON_KEY` | AYON API key from Settings → API Keys |
Optional:
| Variable | Default | Description |
|----------|---------|-------------|
| `AYON_TRANSPORT` | `stdio` | `stdio` for Claude/Cursor, `http` for Docker |
| `PORT` | `8000` | HTTP port (only when `AYON_TRANSPORT=http`) |
### 3a. Use with Claude Desktop / Cursor (stdio)
Add to `claude_desktop_config.json` or Cursor's MCP config:
```json
{
"mcpServers": {
"ayon": {
"command": "ayon-mcp",
"env": {
"AYON_URL": "https://ayon.niklashmotion.art",
"AYON_KEY": "sk-..."
}
}
}
}
```
### 3b. Use with Hermes Agent (stdio)
Add to `~/.hermes/config.yaml`:
```yaml
mcp_servers:
ayon:
command: "ayon-mcp"
env:
AYON_URL: "https://ayon.niklashmotion.art"
AYON_KEY: "sk-..."
```
### 3c. Docker (HTTP transport)
```bash
docker run -d \
--name ayon-mcp \
-e AYON_URL=https://ayon.niklashmotion.art \
-e AYON_KEY=sk-... \
-e AYON_TRANSPORT=http \
-p 8000:8000 \
ghcr.io/niklas/ayon-mcp:latest
```
Then in Hermes `config.yaml`:
```yaml
mcp_servers:
ayon:
url: "http://localhost:8000/mcp"
timeout: 30
```
### 3d. Docker Compose
```yaml
services:
ayon-mcp:
build: .
container_name: mcp-ayon
environment:
- AYON_URL=https://ayon.example.com
- AYON_KEY=sk-...
- AYON_TRANSPORT=http
ports:
- "127.0.0.1:8001:8000"
restart: unless-stopped
```
## Example: Agent publishes a texture set
```
User: Publish the chair textures — BaseColor, Roughness, and Normal maps.
Agent calls ayon_publish_texture_set with:
project: "DemoProject"
folder_path: "/assets/chair"
product_name: "texture_chair"
variant: "wood"
textures: [
{"name": "chair_BaseColor.png", "size": 2097152},
{"name": "chair_Roughness.png", "size": 1048576},
{"name": "chair_Normal.png", "size": 1572864}
]
→ Created product "texture_chair_wood" v1 with 3 texture maps
```
## Architecture
```
AI Agent (Claude Desktop / Cursor / Hermes)
│ MCP (stdio or HTTP)
ayon-mcp server
│ X-Api-Key header
AYON REST API (/api/projects, /api/products, ...)
```
The MCP server is the **only gatekeeper** to AYON. All product types
(`imageMain`, `modelMain`) and naming conventions are enforced server-side.
The AI agent orchestrates the workflow but never touches raw API calls.
### Dual transport: stdio + HTTP
- **stdio** (default): `AYON_TRANSPORT=stdio` or unset. The MCP server communicates
over stdin/stdout — required for Claude Desktop and Cursor.
- **HTTP**: `AYON_TRANSPORT=http`. Listens on `PORT` (default 8000). Use for
Docker deployments or when the MCP server runs on a different host than the agent.
The same binary serves both modes — no separate builds needed.
## Compatibility
| Feature | AYON ≥1.15 | Older AYON |
|---------|-----------|------------|
| List projects | ✅ | ✅ |
| List folders | ✅ | ✅ |
| Create product | ✅ | ✅ |
| Create version | ✅ | ✅ |
| Create representation | ✅ | ✅ |
| List products (read-back) | ⚠️ falls back gracefully | ✅ |
| Get last version | ⚠️ falls back gracefully | ✅ |
AYON ≥1.15 removed the REST endpoints for listing products and versions.
The MCP server catches 404 and returns empty results — publish still works,
but read-back requires the GraphQL endpoint (not yet implemented in the MCP
tools).
## Limitations
- **REST-only**: Products are registered as metadata. Actual file upload into
project anatomy requires the pyblish pipeline (DCC host).
- **No folder creation**: Folders must exist before publishing. Use AYON Web UI
or Tray Publisher to create them first, or create them via REST API.
- **Representations are metadata**: The `files` array contains names/sizes,
not uploaded content. Full file publishing needs a host with pyblish.
For DCC-integrated publishing (files into anatomy, loadable in Maya/Houdini),
combine with [ayon-matrix-publish](https://git.niklashmotion.art/Hermes/ayon-matrix-publish)
or run inside a DCC host with pyblish.
## Related
- [ayon-matrix-publish](https://git.niklashmotion.art/Hermes/ayon-matrix-publish) —
AYON addon that uploads published products to Matrix/Element rooms.
- [AYON REST API Docs](https://docs.ayon.dev/docs/dev_api_rest)
- [Hermes MCP Client](https://hermes-agent.nousresearch.com/docs)
-23
View File
@@ -1,23 +0,0 @@
[project]
name = "ayon-mcp"
version = "0.2.0"
description = "MCP server for AYON: AI agents publish CGI assets via Model Context Protocol. Works with Claude Desktop, Cursor, Hermes, and any MCP-compatible client."
requires-python = ">=3.10"
dependencies = [
"mcp>=1.0.0",
"httpx>=0.27.0",
"uvicorn>=0.30.0",
]
[project.scripts]
ayon-mcp = "ayon_mcp.server:main"
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[tool.setuptools.package-dir]
"" = "src"
[tool.setuptools.packages.find]
where = ["src"]
-3
View File
@@ -1,3 +0,0 @@
"""AYON MCP Server — AI agents publish CGI assets to AYON."""
__version__ = "0.1.0"
-153
View File
@@ -1,153 +0,0 @@
"""AYON REST API client with API key auth."""
import os
from typing import Optional
import httpx
class AyonClient:
"""Minimal wrapper around AYON's REST API.
Requires AYON_URL and AYON_KEY environment variables.
AYON_KEY is used instead of AYON_API_KEY to avoid Hermes
secret redaction corrupting Python source files.
"""
def __init__(
self,
url: Optional[str] = None,
api_key: Optional[str] = None,
):
self.url = (url or os.environ["AYON_URL"]).rstrip("/")
self.api_key = api_key or os.environ["AYON_KEY"]
self._client = httpx.Client(
verify=False,
base_url=self.url,
headers={
"X-Api-Key": self.api_key,
"Content-Type": "application/json",
},
timeout=30,
)
# ── Projects ──────────────────────────────────────────
def list_projects(self) -> list[dict]:
r = self._client.get("/api/projects")
r.raise_for_status()
return r.json()["projects"]
# ── Folders ───────────────────────────────────────────
def list_folders(self, project: str) -> list[dict]:
r = self._client.get(f"/api/projects/{project}/folders")
r.raise_for_status()
return r.json()["folders"]
# ── Products ──────────────────────────────────────────
def list_products(self, project: str, folder_id: str) -> list[dict]:
# AYON >=1.15 removed GET /products?folderId= — returns 404.
try:
r = self._client.get(
f"/api/projects/{project}/products",
params={"folderId": folder_id},
)
r.raise_for_status()
return r.json().get("products", [])
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
return []
raise
def create_product(
self,
project: str,
name: str,
product_type: str,
folder_id: str,
) -> dict:
r = self._client.post(
f"/api/projects/{project}/products",
json={
"name": name,
"productType": product_type,
"folderId": folder_id,
},
)
r.raise_for_status()
return r.json()
# ── Versions ──────────────────────────────────────────
def create_version(
self,
project: str,
product_id: str,
version: int,
task_id: Optional[str] = None,
) -> dict:
body: dict = {"version": version, "productId": product_id}
if task_id:
body["taskId"] = task_id
r = self._client.post(
f"/api/projects/{project}/versions",
json=body,
)
r.raise_for_status()
return r.json()
def get_last_version(self, project: str, product_id: str) -> Optional[dict]:
# AYON >=1.15 removed GET /versions?productId= — returns 404.
try:
r = self._client.get(
f"/api/projects/{project}/versions",
params={"productId": product_id, "latest": "true"},
)
r.raise_for_status()
versions = r.json().get("versions", [])
return versions[0] if versions else None
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
return None
raise
# ── Representations ───────────────────────────────────
def create_representation(
self,
project: str,
version_id: str,
name: str,
files: list[dict],
) -> dict:
"""files: [{"name": "BaseColor.png", "size": 12345}, ...]"""
r = self._client.post(
f"/api/projects/{project}/representations",
json={
"name": name,
"versionId": version_id,
"files": files,
},
)
r.raise_for_status()
return r.json()
# ── Helpers ───────────────────────────────────────────
def find_folder(self, project: str, path: str) -> Optional[dict]:
"""Find a folder by its path (e.g. '/assets/chair')."""
folders = self.list_folders(project)
for f in folders:
if f.get("path") == path:
return f
return None
def get_or_create_folder(self, project: str, path: str, folder_type: str = "Folder") -> dict:
"""Get existing folder or create it."""
f = self.find_folder(project, path)
if f:
return f
# For create we'd need the parent — but let's keep it simple for now
raise NotImplementedError("Folder creation via REST not yet implemented — create manually or via pyblish")
-290
View File
@@ -1,290 +0,0 @@
"""AYON MCP Server — main entry point.
Usage:
AYON_URL=https://ayon.example.com \\
AYON_KEY=sk-... \\
ayon-mcp
Hermes config.yaml:
mcp_servers:
ayon:
command: "ayon-mcp"
env:
AYON_URL: "https://ayon.niklashmotion.art"
AYON_KEY: "sk-..."
"""
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.
Args:
project: Project name
folder_path: Folder path (e.g. '/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.
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")
products = client.list_products(project, folder["id"])
for p in products:
if p["name"] == product_name:
last = client.get_last_version(project, p["id"])
return json.dumps({
"name": p["name"],
"type": p.get("productType"),
"id": p["id"],
"latest_version": last["version"] if last else None,
}, indent=2)
return _err(f"Product '{product_name}' not found in '{folder_path}'")
except Exception as e:
return _err(str(e))
# ── Publishing Tools ─────────────────────────────────────
@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.
Args:
project: Project name (e.g. 'DemoProject')
folder_path: Folder path (e.g. '/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)
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}"
product = client.create_product(
project=project,
name=full_name,
product_type="imageMain",
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="texture_set",
files=textures_list,
)
return _ok(
product=full_name,
type="imageMain",
version=version_num,
maps=[t["name"] for t in textures_list],
folder=folder_path,
product_id=product["id"],
version_id=version["id"],
)
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.).
Args:
project: Project name
folder_path: Folder path (e.g. '/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}]
"""
try:
files_list = json.loads(files)
folder = client.find_folder(project, folder_path)
if not folder:
return _err(f"Folder '{folder_path}' not found")
full_name = product_name
if variant:
full_name = f"{product_name}_{variant}"
product = client.create_product(
project=project,
name=full_name,
product_type="modelMain",
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=model_type,
files=files_list,
)
return _ok(
product=full_name,
type="modelMain",
format=model_type,
version=version_num,
files=[f["name"] for f in files_list],
folder=folder_path,
)
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()