diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..647c763 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +venv/ +__pycache__/ +*.pyc +.eggs/ +dist/ +*.egg-info/ +.git/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..91ebd50 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +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"] diff --git a/README.md b/README.md index 48a5119..25fdcaf 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,13 @@ # 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_publish_texture_set` or `ayon_publish_model` instead of raw HTTP. +`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 @@ -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 ``` -### 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`: @@ -33,26 +71,45 @@ mcp_servers: ayon: command: "ayon-mcp" env: - AYON_SERVER_URL: "https://ayon.niklashmotion.art" - AYON_API_KEY: "sk-..." + AYON_URL: "https://ayon.niklashmotion.art" + 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 -{ - "mcpServers": { - "ayon": { - "command": "ayon-mcp", - "env": { - "AYON_SERVER_URL": "https://ayon.niklashmotion.art", - "AYON_API_KEY": "sk-..." - } - } - } -} +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 @@ -77,9 +134,9 @@ Agent calls ayon_publish_texture_set with: ## Architecture ``` -AI Agent (Hermes / Claude / Cursor) +AI Agent (Claude Desktop / Cursor / Hermes) │ - │ MCP (stdio) + │ MCP (stdio or HTTP) ▼ ayon-mcp server │ X-Api-Key header @@ -87,12 +144,42 @@ ayon-mcp server 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 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. diff --git a/pyproject.toml b/pyproject.toml index 2cf4b6c..b56830c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "ayon-mcp" -version = "0.1.0" -description = "MCP server for AYON: AI agents can publish CGI assets via Model Context Protocol" +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", - "ayon-python-api>=1.0.0", + "uvicorn>=0.30.0", ] [project.scripts] diff --git a/src/ayon_mcp/client.py b/src/ayon_mcp/client.py index ee4fc1a..7ca3a53 100644 --- a/src/ayon_mcp/client.py +++ b/src/ayon_mcp/client.py @@ -22,6 +22,7 @@ class AyonClient: 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, @@ -47,12 +48,18 @@ class AyonClient: # ── Products ────────────────────────────────────────── def list_products(self, project: str, folder_id: str) -> list[dict]: - r = self._client.get( - f"/api/projects/{project}/products", - params={"folderId": folder_id}, - ) - r.raise_for_status() - return r.json()["products"] + # 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, @@ -92,13 +99,19 @@ class AyonClient: return r.json() def get_last_version(self, project: str, product_id: str) -> Optional[dict]: - 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 + # 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 ─────────────────────────────────── diff --git a/src/ayon_mcp/server.py b/src/ayon_mcp/server.py index 9916a1c..2258121 100644 --- a/src/ayon_mcp/server.py +++ b/src/ayon_mcp/server.py @@ -276,7 +276,14 @@ def main(): if not os.environ.get("AYON_KEY"): print("ERROR: AYON_KEY not set", file=sys.stderr) 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__":