Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,19 @@ pip install -r requirements.txt
uvicorn main:app --host 127.0.0.1 --port 8765 --reload
```

The desktop app always starts this server with `MODLY_API_TOKEN` set. Requests other than `GET /health` then require:

```
Authorization: Bearer <token>
```

or `X-Modly-Token: <token>`. Electron writes the token to `userData/api-token` (mode `0600`) so the CLI/MCP client can pick it up. Headless `uvicorn` without the env var stays usable on a trusted machine, but non-loopback `Host` / `Origin` values are still rejected. Set `MODLY_API_ALLOW_REMOTE=1` only when you also set a token and bind beyond loopback.

```bash
export MODLY_API_TOKEN=$(python -c 'import secrets; print(secrets.token_hex(32))')
curl -H "Authorization: Bearer $MODLY_API_TOKEN" http://127.0.0.1:8765/model/all
```

## Key endpoints

| Method | Path | Description |
Expand Down
14 changes: 10 additions & 4 deletions api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
from fastapi import HTTPException

from routers import generation, model, optimize, status, settings, extensions, export, workflow_runs, agent
from services.api_guard import LocalApiGuardMiddleware
from services.local_paths import resolve_workspace_file


@asynccontextmanager
Expand All @@ -35,13 +37,14 @@ def filter(self, record):
lifespan=lifespan,
)

# Last added = outermost. CORS must wrap the guard so 401/403 still get ACAO
# headers; the 3D viewers (file:// / localhost → 127.0.0.1:8765) are cross-origin.
app.add_middleware(LocalApiGuardMiddleware)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_origin_regex=r"^(null|file://.*|app://.*|https?://(localhost|127\.0\.0\.1|\[::1\])(:\d+)?)$",
allow_methods=["*"],
allow_headers=["*"],
# drei's SplatLoader reads Content-Length to size its buffers; cross-origin
# JS can only see it when the server explicitly exposes the header.
expose_headers=["Content-Length"],
)

Expand All @@ -59,7 +62,10 @@ def filter(self, record):
@app.get("/workspace/{full_path:path}")
async def serve_workspace_file(full_path: str):
import services.generator_registry as reg
file_path = reg.WORKSPACE_DIR / full_path
try:
file_path = resolve_workspace_file(reg.WORKSPACE_DIR, full_path)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(str(file_path))
30 changes: 27 additions & 3 deletions api/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,36 @@

import asyncio
import mimetypes
import os
from pathlib import Path

import httpx
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent

API_BASE = "http://localhost:8765"
API_BASE = os.environ.get("MODLY_API_URL", "http://127.0.0.1:8765")


def _api_headers() -> dict[str, str]:
token = os.environ.get("MODLY_API_TOKEN", "").strip()
if not token:
candidates = [
Path.home() / "Library" / "Application Support" / "Modly" / "api-token",
Path(os.environ.get("APPDATA", "")) / "Modly" / "api-token",
Path.home() / ".config" / "Modly" / "api-token",
]
for path in candidates:
if path.is_file():
try:
token = path.read_text(encoding="utf-8").strip()
except OSError:
continue
if token:
break
if not token:
return {}
return {"Authorization": f"Bearer {token}", "X-Modly-Token": token}

server = Server("modly")

Expand Down Expand Up @@ -148,12 +172,12 @@ async def list_tools() -> list[Tool]:

@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
async with httpx.AsyncClient(timeout=60.0) as client:
async with httpx.AsyncClient(timeout=60.0, headers=_api_headers()) as client:
try:
result = await _dispatch(client, name, arguments)
except httpx.ConnectError:
result = (
"Cannot connect to Modly API at http://localhost:8765. "
f"Cannot connect to Modly API at {API_BASE}. "
"Make sure Modly is running."
)
except httpx.HTTPStatusError as e:
Expand Down
15 changes: 13 additions & 2 deletions api/routers/agent.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
"""
Agent chat endpoint — runs an Ollama-powered tool-use loop against Modly's API.
"""
import os
import re
import uuid
import httpx
from fastapi import APIRouter
from pydantic import BaseModel

from services.api_guard import TOKEN_ENV

router = APIRouter(prefix="/agent", tags=["agent"])

MODLY_API = "http://localhost:8765"
MODLY_API = "http://127.0.0.1:8765"


def _modly_headers() -> dict[str, str]:
token = os.environ.get(TOKEN_ENV, "").strip()
if not token:
return {}
return {"Authorization": f"Bearer {token}", "X-Modly-Token": token}

SYSTEM_PROMPT = """\
You are Modly's built-in AI assistant, specialized in 3D modeling and workflow automation.
Expand Down Expand Up @@ -255,7 +265,8 @@ async def execute_tool(name: str, arguments: dict, context: dict) -> tuple[str,
"""Execute a tool and return (result_text, action_payload).
action_payload carries data the frontend needs to react (e.g. new mesh URL).
"""
async with httpx.AsyncClient(timeout=60.0) as client:
headers = _modly_headers()
async with httpx.AsyncClient(timeout=60.0, headers=headers) as client:
try:
if name == "list_models":
r = await client.get(f"{MODLY_API}/model/all")
Expand Down
8 changes: 5 additions & 3 deletions api/routers/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from fastapi.responses import Response, FileResponse

from services.generator_registry import WORKSPACE_DIR
from services.local_paths import resolve_workspace_file

router = APIRouter(tags=["export"])

Expand All @@ -16,9 +17,10 @@ def export_mesh(fmt: str, path: str):
if fmt not in SUPPORTED:
raise HTTPException(400, f"Unsupported format: {fmt}. Supported: {', '.join(SUPPORTED)}")

full_path = (WORKSPACE_DIR / path).resolve()
if not str(full_path).startswith(str(WORKSPACE_DIR.resolve())):
raise HTTPException(400, "Invalid path")
try:
full_path = resolve_workspace_file(WORKSPACE_DIR, path)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
if not full_path.exists():
raise HTTPException(404, f"File not found: {path}")

Expand Down
7 changes: 7 additions & 0 deletions api/routers/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import sys
from fastapi import APIRouter, HTTPException

from services.local_paths import assert_safe_extension_id

router = APIRouter(tags=["extensions"])


Expand Down Expand Up @@ -33,6 +35,11 @@ async def setup_extension(ext_id: str):
if EXTENSIONS_DIR is None or not EXTENSIONS_DIR.exists():
raise HTTPException(400, "EXTENSIONS_DIR not configured")

try:
ext_id = assert_safe_extension_id(ext_id)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc

ext_dir = EXTENSIONS_DIR / ext_id
setup_py = ext_dir / "setup.py"

Expand Down
13 changes: 10 additions & 3 deletions api/routers/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from typing import Optional
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from services.generator_registry import generator_registry, MODELS_DIR

Expand Down Expand Up @@ -124,6 +124,7 @@ async def cancel_hf_download(model_id: str):

@router.get("/hf-download")
async def hf_download(
request: Request,
repo_id: str,
model_id: str,
skip_prefixes: Optional[str] = None,
Expand Down Expand Up @@ -168,8 +169,14 @@ async def hf_download(
except KeyError:
include_list = []

# Token: explicit query param > env var > None
hf_token = token or os.environ.get("HUGGING_FACE_HUB_TOKEN") or os.environ.get("HF_TOKEN") or None
# Prefer header (Electron) over leftover query param, then process env.
hf_token = (
request.headers.get("x-huggingface-token")
or token
or os.environ.get("HUGGING_FACE_HUB_TOKEN")
or os.environ.get("HF_TOKEN")
or None
)
control = _new_download_control(model_id)

async def stream():
Expand Down
50 changes: 27 additions & 23 deletions api/routers/optimize.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from pydantic import BaseModel

from services.generator_registry import WORKSPACE_DIR
from services.local_paths import resolve_readable_mesh_path, resolve_workspace_file

router = APIRouter(tags=["optimize"])

Expand All @@ -47,16 +48,10 @@ def _require_pymeshlab():


def _resolve_input_path(raw_path: str) -> Path:
candidate = Path(raw_path)
if candidate.is_absolute():
resolved = candidate.resolve()
if not resolved.exists():
raise HTTPException(404, f"File not found: {raw_path}")
return resolved

resolved = (WORKSPACE_DIR / raw_path).resolve()
if not str(resolved).startswith(str(WORKSPACE_DIR.resolve())):
raise HTTPException(400, "Invalid path")
try:
resolved = resolve_readable_mesh_path(WORKSPACE_DIR, raw_path)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
if not resolved.exists():
raise HTTPException(404, f"File not found: {raw_path}")
return resolved
Expand Down Expand Up @@ -404,10 +399,15 @@ async def import_mesh_by_path(body: ImportByPathRequest):
if ext not in ("glb", "obj", "stl", "ply", "splat"):
raise HTTPException(400, f"Unsupported format: {ext}")

# Gaussian Splat: serve a .splat as-is, convert a GS .ply to .splat.
# The viewer detects splats by the .splat/.ply extension in the served URL.
def _imported(src: Path, dest_name: str) -> str:
tmp_dir = tempfile.mkdtemp(prefix="modly_import_")
dest = Path(tmp_dir) / dest_name
shutil.copy2(src, dest)
return f"/optimize/serve-file?path={quote(str(dest))}"

# Gaussian Splat: copy into a Modly temp dir so serve-file stays jailed.
if ext == "splat":
return {"url": f"/optimize/serve-file?path={quote(str(file_path))}"}
return {"url": _imported(file_path, "splat.splat")}

if ext == "ply" and _is_gaussian_ply(file_path):
tmp_dir = tempfile.mkdtemp(prefix="modly_import_")
Expand All @@ -419,8 +419,7 @@ async def import_mesh_by_path(body: ImportByPathRequest):
return {"url": f"/optimize/serve-file?path={quote(output_path)}"}

if ext == "glb":
# Serve the original file directly — no copy
return {"url": f"/optimize/serve-file?path={quote(str(file_path))}"}
return {"url": _imported(file_path, "mesh.glb")}

# Mesh ply / obj / stl: convert to GLB in a temp directory (not the workspace)
tmp_dir = tempfile.mkdtemp(prefix="modly_import_")
Expand All @@ -438,7 +437,10 @@ async def import_mesh_by_path(body: ImportByPathRequest):

@router.get("/serve-file")
def serve_file(path: str):
file_path = Path(path)
try:
file_path = resolve_readable_mesh_path(WORKSPACE_DIR, path)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
if not file_path.is_file():
raise HTTPException(404, "File not found")
media_type = _SERVE_MEDIA_TYPES.get(file_path.suffix.lower())
Expand All @@ -455,10 +457,11 @@ def ply_to_splat(path: str):
as-is; a GS .ply is normalised + converted (cached by mtime + conv version).
"""
import services.generator_registry as reg # dynamic: workspace dir may change at runtime
workspace = reg.WORKSPACE_DIR.resolve()
src = (workspace / path).resolve()
if not str(src).startswith(str(workspace)):
raise HTTPException(400, "Invalid path")
workspace = reg.WORKSPACE_DIR
try:
src = resolve_workspace_file(workspace, path)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
if not src.is_file():
raise HTTPException(404, "File not found")

Expand All @@ -482,9 +485,10 @@ def export_mesh(path: str, format: str):
if format not in ("obj", "stl", "ply"):
raise HTTPException(400, "Supported formats: obj, stl, ply")

input_path = (WORKSPACE_DIR / path).resolve()
if not str(input_path).startswith(str(WORKSPACE_DIR.resolve())):
raise HTTPException(400, "Invalid path")
try:
input_path = resolve_workspace_file(WORKSPACE_DIR, path)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
if not input_path.exists():
raise HTTPException(404, f"File not found: {path}")

Expand Down
6 changes: 4 additions & 2 deletions api/routers/status.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from fastapi import APIRouter

from services.api_guard import auth_mode

router = APIRouter(tags=["health"])


@router.get("/health")
async def health():
"""Health check — used by Electron to know the API is ready."""
return {"status": "ok"}
"""Health check — used by Electron to know the API is ready. Unauthenticated on purpose."""
return {"status": "ok", "auth": auth_mode()}
Loading