Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 21af24b304 | |||
| c42b18ecd9 | |||
| f22b567df0 | |||
| 049b3c0901 | |||
| 76683d5e24 | |||
| 3b0f64648b |
@@ -0,0 +1,7 @@
|
|||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.eggs/
|
||||||
|
dist/
|
||||||
|
*.egg-info/
|
||||||
|
.git/
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY pyproject.toml .
|
||||||
|
COPY src/ src/
|
||||||
|
RUN pip install --no-cache-dir .
|
||||||
|
|
||||||
|
ENV AYON_TRANSPORT=http
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["ayon-mcp"]
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
# AYON MCP Server
|
# AYON MCP Server
|
||||||
|
|
||||||
**AI agents can publish CGI assets to AYON via Model Context Protocol.**
|
**AI agents publish CGI assets to AYON via Model Context Protocol.**
|
||||||
|
|
||||||
MCP server that wraps AYON's REST API — agents use simple tool calls like
|
`ayon-mcp` wraps AYON's REST API into six MCP tools — any MCP-compatible agent
|
||||||
`ayon_publish_texture_set` or `ayon_publish_model` instead of raw HTTP.
|
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
|
## Tools
|
||||||
|
|
||||||
@@ -24,7 +28,41 @@ MCP server that wraps AYON's REST API — agents use simple tool calls like
|
|||||||
pip install git+https://git.niklashmotion.art/Hermes/ayon-mcp.git
|
pip install git+https://git.niklashmotion.art/Hermes/ayon-mcp.git
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Configure Hermes Agent
|
### 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`:
|
Add to `~/.hermes/config.yaml`:
|
||||||
|
|
||||||
@@ -33,26 +71,45 @@ mcp_servers:
|
|||||||
ayon:
|
ayon:
|
||||||
command: "ayon-mcp"
|
command: "ayon-mcp"
|
||||||
env:
|
env:
|
||||||
AYON_SERVER_URL: "https://ayon.niklashmotion.art"
|
AYON_URL: "https://ayon.niklashmotion.art"
|
||||||
AYON_API_KEY: "sk-..."
|
AYON_KEY: "sk-..."
|
||||||
```
|
```
|
||||||
|
|
||||||
Restart Hermes Agent — tools appear as `mcp_ayon_ayon_list_projects` etc.
|
### 3c. Docker (HTTP transport)
|
||||||
|
|
||||||
### 3. Use with any MCP client
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
```json
|
Then in Hermes `config.yaml`:
|
||||||
{
|
|
||||||
"mcpServers": {
|
```yaml
|
||||||
"ayon": {
|
mcp_servers:
|
||||||
"command": "ayon-mcp",
|
ayon:
|
||||||
"env": {
|
url: "http://localhost:8000/mcp"
|
||||||
"AYON_SERVER_URL": "https://ayon.niklashmotion.art",
|
timeout: 30
|
||||||
"AYON_API_KEY": "sk-..."
|
```
|
||||||
}
|
|
||||||
}
|
### 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
|
## Example: Agent publishes a texture set
|
||||||
@@ -77,9 +134,9 @@ Agent calls ayon_publish_texture_set with:
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
AI Agent (Hermes / Claude / Cursor)
|
AI Agent (Claude Desktop / Cursor / Hermes)
|
||||||
│
|
│
|
||||||
│ MCP (stdio)
|
│ MCP (stdio or HTTP)
|
||||||
▼
|
▼
|
||||||
ayon-mcp server
|
ayon-mcp server
|
||||||
│ X-Api-Key header
|
│ X-Api-Key header
|
||||||
@@ -87,12 +144,42 @@ ayon-mcp server
|
|||||||
AYON REST API (/api/projects, /api/products, ...)
|
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
|
## Limitations
|
||||||
|
|
||||||
- **REST-only**: Products are registered as metadata. Actual file upload into
|
- **REST-only**: Products are registered as metadata. Actual file upload into
|
||||||
project anatomy requires the pyblish pipeline (DCC host).
|
project anatomy requires the pyblish pipeline (DCC host).
|
||||||
- **No folder creation**: Folders must exist before publishing. Use AYON Web UI
|
- **No folder creation**: Folders must exist before publishing. Use AYON Web UI
|
||||||
or Tray Publisher to create them first.
|
or Tray Publisher to create them first, or create them via REST API.
|
||||||
- **Representations are metadata**: The `files` array contains names/sizes,
|
- **Representations are metadata**: The `files` array contains names/sizes,
|
||||||
not uploaded content. Full file publishing needs a host with pyblish.
|
not uploaded content. Full file publishing needs a host with pyblish.
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -1,12 +1,12 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "ayon-mcp"
|
name = "ayon-mcp"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
description = "MCP server for AYON: AI agents can publish CGI assets via Model Context Protocol"
|
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"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"mcp>=1.0.0",
|
"mcp>=1.0.0",
|
||||||
"httpx>=0.27.0",
|
"httpx>=0.27.0",
|
||||||
"ayon-python-api>=1.0.0",
|
"uvicorn>=0.30.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
+179
-34
@@ -1,5 +1,6 @@
|
|||||||
"""AYON REST API client with API key auth."""
|
"""AYON REST + GraphQL API client with API key auth."""
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -7,7 +8,7 @@ import httpx
|
|||||||
|
|
||||||
|
|
||||||
class AyonClient:
|
class AyonClient:
|
||||||
"""Minimal wrapper around AYON's REST API.
|
"""Wrapper around AYON's REST and GraphQL APIs.
|
||||||
|
|
||||||
Requires AYON_URL and AYON_KEY environment variables.
|
Requires AYON_URL and AYON_KEY environment variables.
|
||||||
AYON_KEY is used instead of AYON_API_KEY to avoid Hermes
|
AYON_KEY is used instead of AYON_API_KEY to avoid Hermes
|
||||||
@@ -22,6 +23,7 @@ class AyonClient:
|
|||||||
self.url = (url or os.environ["AYON_URL"]).rstrip("/")
|
self.url = (url or os.environ["AYON_URL"]).rstrip("/")
|
||||||
self.api_key = api_key or os.environ["AYON_KEY"]
|
self.api_key = api_key or os.environ["AYON_KEY"]
|
||||||
self._client = httpx.Client(
|
self._client = httpx.Client(
|
||||||
|
verify=False,
|
||||||
base_url=self.url,
|
base_url=self.url,
|
||||||
headers={
|
headers={
|
||||||
"X-Api-Key": self.api_key,
|
"X-Api-Key": self.api_key,
|
||||||
@@ -30,6 +32,17 @@ class AyonClient:
|
|||||||
timeout=30,
|
timeout=30,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── GraphQL helper ────────────────────────────────────
|
||||||
|
|
||||||
|
def _graphql(self, query: str) -> dict:
|
||||||
|
"""Execute a GraphQL query. Returns data dict or raises."""
|
||||||
|
r = self._client.post("/graphql", json={"query": query})
|
||||||
|
r.raise_for_status()
|
||||||
|
body = r.json()
|
||||||
|
if body.get("errors"):
|
||||||
|
raise RuntimeError(body["errors"][0]["message"])
|
||||||
|
return body.get("data", {})
|
||||||
|
|
||||||
# ── Projects ──────────────────────────────────────────
|
# ── Projects ──────────────────────────────────────────
|
||||||
|
|
||||||
def list_projects(self) -> list[dict]:
|
def list_projects(self) -> list[dict]:
|
||||||
@@ -44,15 +57,140 @@ class AyonClient:
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()["folders"]
|
return r.json()["folders"]
|
||||||
|
|
||||||
# ── Products ──────────────────────────────────────────
|
def find_folder(self, project: str, path: str) -> Optional[dict]:
|
||||||
|
"""Find a folder by its path.
|
||||||
|
|
||||||
def list_products(self, project: str, folder_id: str) -> list[dict]:
|
Normalises leading slash — both '/assets/chair' and 'assets/chair' work.
|
||||||
r = self._client.get(
|
"""
|
||||||
f"/api/projects/{project}/products",
|
clean = path.lstrip("/")
|
||||||
params={"folderId": folder_id},
|
folders = self.list_folders(project)
|
||||||
|
for f in folders:
|
||||||
|
fp = f.get("path", "").lstrip("/")
|
||||||
|
if fp == clean:
|
||||||
|
return f
|
||||||
|
return None
|
||||||
|
|
||||||
|
def create_folder(
|
||||||
|
self,
|
||||||
|
project: str,
|
||||||
|
name: str,
|
||||||
|
parent_id: str,
|
||||||
|
folder_type: str = "Asset",
|
||||||
|
) -> dict:
|
||||||
|
"""Create a new folder under a parent. Returns full folder dict."""
|
||||||
|
r = self._client.post(
|
||||||
|
f"/api/projects/{project}/folders",
|
||||||
|
json={
|
||||||
|
"name": name,
|
||||||
|
"parentId": parent_id,
|
||||||
|
"folderType": folder_type,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()["products"]
|
folder_id = r.json()["id"]
|
||||||
|
# Re-fetch to get full folder details (including name/path)
|
||||||
|
return self._get_folder(project, folder_id)
|
||||||
|
|
||||||
|
def _get_folder(self, project: str, folder_id: str) -> dict:
|
||||||
|
"""Get a single folder by ID via GraphQL."""
|
||||||
|
query = (
|
||||||
|
'{project(name:"' + project + '")'
|
||||||
|
'{folder(id:"' + folder_id + '")'
|
||||||
|
"{id name path parentId folderType}}}"
|
||||||
|
)
|
||||||
|
data = self._graphql(query)
|
||||||
|
return data["project"]["folder"]
|
||||||
|
|
||||||
|
def find_or_create_folder(
|
||||||
|
self, project: str, path: str, folder_type: str = "Asset"
|
||||||
|
) -> dict:
|
||||||
|
"""Find existing folder or create it (including parents).
|
||||||
|
|
||||||
|
Walks the path component by component — each component must match
|
||||||
|
the exact folder name under the current parent, not just endswith.
|
||||||
|
"""
|
||||||
|
f = self.find_folder(project, path)
|
||||||
|
if f:
|
||||||
|
return f
|
||||||
|
|
||||||
|
parts = path.strip("/").split("/")
|
||||||
|
# Resolve root-level folders (no parentId)
|
||||||
|
folders = self.list_folders(project)
|
||||||
|
|
||||||
|
current_parent = None
|
||||||
|
for part in parts:
|
||||||
|
found = None
|
||||||
|
for f in folders:
|
||||||
|
fp = f.get("path", "").lstrip("/")
|
||||||
|
pid = f.get("parentId") or ""
|
||||||
|
|
||||||
|
if current_parent is None:
|
||||||
|
# Root level: name matches and no parent
|
||||||
|
if f.get("name") == part and not pid:
|
||||||
|
found = f
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# Child level: name matches AND parentId matches
|
||||||
|
if f.get("name") == part and pid == current_parent["id"]:
|
||||||
|
found = f
|
||||||
|
break
|
||||||
|
|
||||||
|
if found:
|
||||||
|
current_parent = found
|
||||||
|
else:
|
||||||
|
parent_id = current_parent["id"] if current_parent else self._find_root_folder_id(project)
|
||||||
|
current_parent = self.create_folder(
|
||||||
|
project, part, parent_id, folder_type
|
||||||
|
)
|
||||||
|
# Refresh folder list for next iteration
|
||||||
|
folders = self.list_folders(project)
|
||||||
|
|
||||||
|
if not current_parent:
|
||||||
|
raise RuntimeError(f"Could not find or create folder '{path}'")
|
||||||
|
return current_parent
|
||||||
|
|
||||||
|
def _find_root_folder_id(self, project: str) -> str:
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
path_parts = urlparse(self.url).path.strip("/").split("/")
|
||||||
|
# Default to 'assets' parent — find it
|
||||||
|
folders = self.list_folders(project)
|
||||||
|
for f in folders:
|
||||||
|
if f.get("name") == "assets" and not f.get("parentId"):
|
||||||
|
return f["id"]
|
||||||
|
# Fallback: first folder without parent
|
||||||
|
for f in folders:
|
||||||
|
if not f.get("parentId"):
|
||||||
|
return f["id"]
|
||||||
|
raise RuntimeError(f"No root folder found in project '{project}'")
|
||||||
|
|
||||||
|
# ── Products (via GraphQL, AYON >=1.15) ───────────────
|
||||||
|
|
||||||
|
def list_products(self, project: str, folder_id: str) -> list[dict]:
|
||||||
|
"""List products in a folder via GraphQL (REST endpoint removed in >=1.15)."""
|
||||||
|
query = (
|
||||||
|
'{project(name:"' + project + '")'
|
||||||
|
'{folder(id:"' + folder_id + '")'
|
||||||
|
"{products{edges{node{id name productType createdAt updatedAt}}}}}}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
data = self._graphql(query)
|
||||||
|
folder = data.get("project", {}).get("folder")
|
||||||
|
if not folder:
|
||||||
|
return []
|
||||||
|
edges = folder.get("products", {}).get("edges", [])
|
||||||
|
return [e["node"] for e in edges]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def find_product_by_name(
|
||||||
|
self, project: str, folder_id: str, product_name: str
|
||||||
|
) -> Optional[dict]:
|
||||||
|
"""Find a product by name in a folder. Returns None if not found."""
|
||||||
|
products = self.list_products(project, folder_id)
|
||||||
|
for p in products:
|
||||||
|
if p["name"] == product_name:
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
def create_product(
|
def create_product(
|
||||||
self,
|
self,
|
||||||
@@ -92,13 +230,26 @@ class AyonClient:
|
|||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
def get_last_version(self, project: str, product_id: str) -> Optional[dict]:
|
def get_last_version(self, project: str, product_id: str) -> Optional[dict]:
|
||||||
r = self._client.get(
|
"""Get latest version via GraphQL (REST endpoint removed in >=1.15)."""
|
||||||
f"/api/projects/{project}/versions",
|
query = (
|
||||||
params={"productId": product_id, "latest": "true"},
|
'{project(name:"' + project + '")'
|
||||||
|
'{product(id:"' + product_id + '")'
|
||||||
|
"{versions{edges{node{id version author createdAt}}}}}}"
|
||||||
)
|
)
|
||||||
r.raise_for_status()
|
try:
|
||||||
versions = r.json().get("versions", [])
|
data = self._graphql(query)
|
||||||
return versions[0] if versions else None
|
product = data.get("project", {}).get("product")
|
||||||
|
if not product:
|
||||||
|
return None
|
||||||
|
edges = product.get("versions", {}).get("edges", [])
|
||||||
|
if not edges:
|
||||||
|
return None
|
||||||
|
# Sort by version desc
|
||||||
|
versions = [e["node"] for e in edges]
|
||||||
|
versions.sort(key=lambda v: v["version"], reverse=True)
|
||||||
|
return versions[0]
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
# ── Representations ───────────────────────────────────
|
# ── Representations ───────────────────────────────────
|
||||||
|
|
||||||
@@ -109,32 +260,26 @@ class AyonClient:
|
|||||||
name: str,
|
name: str,
|
||||||
files: list[dict],
|
files: list[dict],
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""files: [{"name": "BaseColor.png", "size": 12345}, ...]"""
|
"""files: [{"name": "BaseColor.png", "size": 12345}, ...]
|
||||||
|
|
||||||
|
Files are enriched with required 'id' (UUID) and 'path' fields.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
enriched = []
|
||||||
|
for f in files:
|
||||||
|
enriched.append({
|
||||||
|
"id": uuid.uuid4().hex,
|
||||||
|
"name": f.get("name", f.get("path", "file")),
|
||||||
|
"path": f.get("path", f"/tmp/{f.get('name', 'file')}"),
|
||||||
|
"size": f.get("size", 0),
|
||||||
|
})
|
||||||
r = self._client.post(
|
r = self._client.post(
|
||||||
f"/api/projects/{project}/representations",
|
f"/api/projects/{project}/representations",
|
||||||
json={
|
json={
|
||||||
"name": name,
|
"name": name,
|
||||||
"versionId": version_id,
|
"versionId": version_id,
|
||||||
"files": files,
|
"files": enriched,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
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")
|
|
||||||
|
|||||||
+165
-85
@@ -1,17 +1,12 @@
|
|||||||
"""AYON MCP Server — main entry point.
|
"""AYON MCP Server — main entry point.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
AYON_URL=https://ayon.example.com \\
|
AYON_URL=http://172.18.0.2:5000 \\
|
||||||
AYON_KEY=sk-... \\
|
AYON_KEY=sk-... \\
|
||||||
ayon-mcp
|
ayon-mcp
|
||||||
|
|
||||||
Hermes config.yaml:
|
Supports stdio (Claude Desktop, Cursor) and HTTP (Docker, Hermes) transport.
|
||||||
mcp_servers:
|
Set AYON_TRANSPORT=http for HTTP mode.
|
||||||
ayon:
|
|
||||||
command: "ayon-mcp"
|
|
||||||
env:
|
|
||||||
AYON_URL: "https://ayon.niklashmotion.art"
|
|
||||||
AYON_KEY: "sk-..."
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -78,9 +73,12 @@ async def ayon_list_folders(project: str) -> str:
|
|||||||
async def ayon_list_products(project: str, folder_path: str) -> str:
|
async def ayon_list_products(project: str, folder_path: str) -> str:
|
||||||
"""List published products in a folder.
|
"""List published products in a folder.
|
||||||
|
|
||||||
|
Uses GraphQL for AYON >=1.15 compatibility.
|
||||||
|
Accepts paths with or without leading slash.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
project: Project name
|
project: Project name
|
||||||
folder_path: Folder path (e.g. '/assets/chair')
|
folder_path: Folder path (e.g. '/assets/chair' or 'assets/chair')
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
folder = client.find_folder(project, folder_path)
|
folder = client.find_folder(project, folder_path)
|
||||||
@@ -98,7 +96,9 @@ async def ayon_list_products(project: str, folder_path: str) -> str:
|
|||||||
|
|
||||||
@app.tool()
|
@app.tool()
|
||||||
async def ayon_get_product(project: str, folder_path: str, product_name: str) -> str:
|
async def ayon_get_product(project: str, folder_path: str, product_name: str) -> str:
|
||||||
"""Get details about a specific product.
|
"""Get details about a specific product, including latest version.
|
||||||
|
|
||||||
|
Uses GraphQL for AYON >=1.15 compatibility.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
project: Project name
|
project: Project name
|
||||||
@@ -109,56 +109,115 @@ async def ayon_get_product(project: str, folder_path: str, product_name: str) ->
|
|||||||
folder = client.find_folder(project, folder_path)
|
folder = client.find_folder(project, folder_path)
|
||||||
if not folder:
|
if not folder:
|
||||||
return _err(f"Folder '{folder_path}' not found")
|
return _err(f"Folder '{folder_path}' not found")
|
||||||
products = client.list_products(project, folder["id"])
|
product = client.find_product_by_name(project, folder["id"], product_name)
|
||||||
for p in products:
|
if not product:
|
||||||
if p["name"] == product_name:
|
return _err(f"Product '{product_name}' not found in '{folder_path}'")
|
||||||
last = client.get_last_version(project, p["id"])
|
last = client.get_last_version(project, product["id"])
|
||||||
return json.dumps({
|
return json.dumps({
|
||||||
"name": p["name"],
|
"name": product["name"],
|
||||||
"type": p.get("productType"),
|
"type": product.get("productType"),
|
||||||
"id": p["id"],
|
"id": product["id"],
|
||||||
"latest_version": last["version"] if last else None,
|
"latest_version": last["version"] if last else None,
|
||||||
}, indent=2)
|
}, indent=2)
|
||||||
return _err(f"Product '{product_name}' not found in '{folder_path}'")
|
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:
|
except Exception as e:
|
||||||
return _err(str(e))
|
return _err(str(e))
|
||||||
|
|
||||||
|
|
||||||
# ── Publishing Tools ─────────────────────────────────────
|
# ── Publishing Tools ─────────────────────────────────────
|
||||||
|
|
||||||
@app.tool()
|
def _publish_product(
|
||||||
async def ayon_publish_texture_set(
|
|
||||||
project: str,
|
project: str,
|
||||||
folder_path: str,
|
folder_path: str,
|
||||||
product_name: str,
|
product_name: str,
|
||||||
|
product_type: str,
|
||||||
|
representation_name: str,
|
||||||
|
files_list: list[dict],
|
||||||
variant: str = "",
|
variant: str = "",
|
||||||
textures: str = "[]",
|
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Publish a texture set (multiple maps) as a single product.
|
"""Core publish logic — creates product or auto-versions existing one.
|
||||||
|
|
||||||
Args:
|
Returns a JSON string with status and details.
|
||||||
project: Project name (e.g. 'DemoProject')
|
The caller decides if this is modelMain or imageMain.
|
||||||
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:
|
folder = client.find_folder(project, folder_path)
|
||||||
textures_list = json.loads(textures)
|
if not folder:
|
||||||
|
return _err(f"Folder '{folder_path}' not found in '{project}'")
|
||||||
|
|
||||||
folder = client.find_folder(project, folder_path)
|
full_name = product_name
|
||||||
if not folder:
|
if variant:
|
||||||
return _err(f"Folder '{folder_path}' not found in '{project}'")
|
full_name = f"{product_name}_{variant}"
|
||||||
|
|
||||||
full_name = product_name
|
# Check if product already exists
|
||||||
if variant:
|
existing = client.find_product_by_name(project, folder["id"], full_name)
|
||||||
full_name = f"{product_name}_{variant}"
|
|
||||||
|
|
||||||
|
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(
|
product = client.create_product(
|
||||||
project=project,
|
project=project,
|
||||||
name=full_name,
|
name=full_name,
|
||||||
product_type="imageMain",
|
product_type=product_type,
|
||||||
folder_id=folder["id"],
|
folder_id=folder["id"],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -174,18 +233,55 @@ async def ayon_publish_texture_set(
|
|||||||
rep = client.create_representation(
|
rep = client.create_representation(
|
||||||
project=project,
|
project=project,
|
||||||
version_id=version["id"],
|
version_id=version["id"],
|
||||||
name="texture_set",
|
name=representation_name,
|
||||||
files=textures_list,
|
files=files_list,
|
||||||
)
|
)
|
||||||
|
|
||||||
return _ok(
|
return _ok(
|
||||||
|
action="created_new",
|
||||||
product=full_name,
|
product=full_name,
|
||||||
type="imageMain",
|
type=product_type,
|
||||||
version=version_num,
|
version=version_num,
|
||||||
maps=[t["name"] for t in textures_list],
|
existing_product=False,
|
||||||
folder=folder_path,
|
|
||||||
product_id=product["id"],
|
product_id=product["id"],
|
||||||
version_id=version["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:
|
except Exception as e:
|
||||||
return _err(str(e))
|
return _err(str(e))
|
||||||
@@ -202,56 +298,33 @@ async def ayon_publish_model(
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""Publish a 3D model (ABC, USD, FBX, etc.).
|
"""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:
|
Args:
|
||||||
project: Project name
|
project: Project name
|
||||||
folder_path: Folder path (e.g. '/assets/chair')
|
folder_path: Folder path (e.g. '/assets/chair' or 'assets/chair')
|
||||||
product_name: Product name (e.g. 'modelMain')
|
product_name: Product name (e.g. 'modelMain')
|
||||||
model_type: File format: 'abc', 'usd', 'fbx', 'obj', 'blend'
|
model_type: File format: 'abc', 'usd', 'fbx', 'obj', 'blend'
|
||||||
variant: Optional variant (e.g. 'highpoly')
|
variant: Optional variant (e.g. 'highpoly')
|
||||||
files: JSON array of file info:
|
files: JSON array of file info:
|
||||||
[{"name": "chair.abc", "size": 2048000}]
|
[{"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:
|
try:
|
||||||
files_list = json.loads(files)
|
files_list = json.loads(files)
|
||||||
|
return _publish_product(
|
||||||
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,
|
project=project,
|
||||||
name=full_name,
|
folder_path=folder_path,
|
||||||
|
product_name=product_name,
|
||||||
product_type="modelMain",
|
product_type="modelMain",
|
||||||
folder_id=folder["id"],
|
representation_name=model_type,
|
||||||
)
|
files_list=files_list,
|
||||||
|
variant=variant,
|
||||||
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:
|
except Exception as e:
|
||||||
return _err(str(e))
|
return _err(str(e))
|
||||||
@@ -276,7 +349,14 @@ def main():
|
|||||||
if not os.environ.get("AYON_KEY"):
|
if not os.environ.get("AYON_KEY"):
|
||||||
print("ERROR: AYON_KEY not set", file=sys.stderr)
|
print("ERROR: AYON_KEY not set", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
app.run()
|
|
||||||
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user