mocapless-mcp 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mocapless_mcp-0.1.0/.gitignore +14 -0
- mocapless_mcp-0.1.0/PKG-INFO +89 -0
- mocapless_mcp-0.1.0/README.md +72 -0
- mocapless_mcp-0.1.0/mocapless_mcp/__init__.py +3 -0
- mocapless_mcp-0.1.0/mocapless_mcp/client.py +185 -0
- mocapless_mcp-0.1.0/mocapless_mcp/server.py +200 -0
- mocapless_mcp-0.1.0/pyproject.toml +38 -0
- mocapless_mcp-0.1.0/tests/test_server.py +171 -0
- mocapless_mcp-0.1.0/uv.lock +1031 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mocapless-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for Mocapless: generate character animation from text inside Claude, Cursor and any MCP client.
|
|
5
|
+
Project-URL: Homepage, https://mocapless.com
|
|
6
|
+
Project-URL: Documentation, https://mocapless.com/developers
|
|
7
|
+
Author-email: Mocapless <hello@mocapless.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: animation,fbx,mcp,mocap,text-to-motion
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: Multimedia :: Graphics :: 3D Modeling
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Requires-Dist: httpx>=0.27
|
|
14
|
+
Requires-Dist: mcp<2,>=1.10
|
|
15
|
+
Requires-Dist: pydantic>=2.5
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# mocapless-mcp
|
|
19
|
+
|
|
20
|
+
Generate character animation from a sentence, inside Claude Desktop, Claude Code, Cursor, Windsurf or any MCP client — on your own rigged character, exported as FBX / GLB / BVH.
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
"Animate my character: a tired soldier limps forward, stops, looks over his shoulder, then sits down."
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The agent plans it, quotes the price, generates it, waits, and saves the files.
|
|
27
|
+
|
|
28
|
+
## Setup
|
|
29
|
+
|
|
30
|
+
1. Get an API key: [mocapless.com/account](https://mocapless.com/account) → API keys. New accounts have 60 free credits (about a minute of animation) and a demo character ready to animate.
|
|
31
|
+
2. Add the server to your client.
|
|
32
|
+
|
|
33
|
+
**Claude Desktop** — `claude_desktop_config.json`:
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{
|
|
37
|
+
"mcpServers": {
|
|
38
|
+
"mocapless": {
|
|
39
|
+
"command": "uvx",
|
|
40
|
+
"args": ["mocapless-mcp"],
|
|
41
|
+
"env": { "MOCAPLESS_API_KEY": "mf_..." }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**Claude Code**:
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
claude mcp add mocapless -e MOCAPLESS_API_KEY=mf_... -- uvx mocapless-mcp
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**Cursor / Windsurf** — same JSON under `mcpServers` in the client's MCP settings.
|
|
54
|
+
|
|
55
|
+
`uvx` comes with [uv](https://docs.astral.sh/uv/). With plain Python: `pip install mocapless-mcp` and use `"command": "mocapless-mcp"`.
|
|
56
|
+
|
|
57
|
+
## Tools
|
|
58
|
+
|
|
59
|
+
| Tool | What it does |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `account` | Credits left, plan. |
|
|
62
|
+
| `list_characters` | Your characters (the demo mannequin is there on a new account). |
|
|
63
|
+
| `upload_character(path)` | Upload an FBX/GLB/DAE — rigged or not — and wait until it is ready. |
|
|
64
|
+
| `plan_motion(prompt)` | Free: the beats, the engine and the price for a prompt. |
|
|
65
|
+
| `generate_animation(prompt, character_id?, formats?, loop?, quality?, wait?, download_dir?)` | Generate, wait, download. |
|
|
66
|
+
| `get_job(job_id, download_dir?)` | Status and files of a generation. |
|
|
67
|
+
| `recent_animations(limit?)` | Your recent generations. |
|
|
68
|
+
| `share_link(job_id)` | A public link to watch the clip in the browser. |
|
|
69
|
+
|
|
70
|
+
Files land in `~/mocapless/<job>/` unless `download_dir` or `MOCAPLESS_DOWNLOAD_DIR` says otherwise.
|
|
71
|
+
|
|
72
|
+
## Prompting
|
|
73
|
+
|
|
74
|
+
Plain present-tense English about the body, in order. One to four sentences. Say which hand, which direction, what mood; leave out camera and scene. Up to 30 seconds per clip. `loop: true` for idles, walks and dances that should cycle.
|
|
75
|
+
|
|
76
|
+
## With an engine MCP
|
|
77
|
+
|
|
78
|
+
Combined with an Unreal or Unity MCP server, one request can export the character, generate the motion here, and import the result into the project.
|
|
79
|
+
|
|
80
|
+
## Development
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
cd mcp
|
|
84
|
+
uv sync
|
|
85
|
+
uv run pytest
|
|
86
|
+
uv run mocapless-mcp # stdio server
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Environment: `MOCAPLESS_API_KEY` (required), `MOCAPLESS_API_BASE` (default `https://api.mocapless.com`), `MOCAPLESS_DOWNLOAD_DIR`.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# mocapless-mcp
|
|
2
|
+
|
|
3
|
+
Generate character animation from a sentence, inside Claude Desktop, Claude Code, Cursor, Windsurf or any MCP client — on your own rigged character, exported as FBX / GLB / BVH.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
"Animate my character: a tired soldier limps forward, stops, looks over his shoulder, then sits down."
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The agent plans it, quotes the price, generates it, waits, and saves the files.
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
1. Get an API key: [mocapless.com/account](https://mocapless.com/account) → API keys. New accounts have 60 free credits (about a minute of animation) and a demo character ready to animate.
|
|
14
|
+
2. Add the server to your client.
|
|
15
|
+
|
|
16
|
+
**Claude Desktop** — `claude_desktop_config.json`:
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
{
|
|
20
|
+
"mcpServers": {
|
|
21
|
+
"mocapless": {
|
|
22
|
+
"command": "uvx",
|
|
23
|
+
"args": ["mocapless-mcp"],
|
|
24
|
+
"env": { "MOCAPLESS_API_KEY": "mf_..." }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**Claude Code**:
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
claude mcp add mocapless -e MOCAPLESS_API_KEY=mf_... -- uvx mocapless-mcp
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Cursor / Windsurf** — same JSON under `mcpServers` in the client's MCP settings.
|
|
37
|
+
|
|
38
|
+
`uvx` comes with [uv](https://docs.astral.sh/uv/). With plain Python: `pip install mocapless-mcp` and use `"command": "mocapless-mcp"`.
|
|
39
|
+
|
|
40
|
+
## Tools
|
|
41
|
+
|
|
42
|
+
| Tool | What it does |
|
|
43
|
+
|---|---|
|
|
44
|
+
| `account` | Credits left, plan. |
|
|
45
|
+
| `list_characters` | Your characters (the demo mannequin is there on a new account). |
|
|
46
|
+
| `upload_character(path)` | Upload an FBX/GLB/DAE — rigged or not — and wait until it is ready. |
|
|
47
|
+
| `plan_motion(prompt)` | Free: the beats, the engine and the price for a prompt. |
|
|
48
|
+
| `generate_animation(prompt, character_id?, formats?, loop?, quality?, wait?, download_dir?)` | Generate, wait, download. |
|
|
49
|
+
| `get_job(job_id, download_dir?)` | Status and files of a generation. |
|
|
50
|
+
| `recent_animations(limit?)` | Your recent generations. |
|
|
51
|
+
| `share_link(job_id)` | A public link to watch the clip in the browser. |
|
|
52
|
+
|
|
53
|
+
Files land in `~/mocapless/<job>/` unless `download_dir` or `MOCAPLESS_DOWNLOAD_DIR` says otherwise.
|
|
54
|
+
|
|
55
|
+
## Prompting
|
|
56
|
+
|
|
57
|
+
Plain present-tense English about the body, in order. One to four sentences. Say which hand, which direction, what mood; leave out camera and scene. Up to 30 seconds per clip. `loop: true` for idles, walks and dances that should cycle.
|
|
58
|
+
|
|
59
|
+
## With an engine MCP
|
|
60
|
+
|
|
61
|
+
Combined with an Unreal or Unity MCP server, one request can export the character, generate the motion here, and import the result into the project.
|
|
62
|
+
|
|
63
|
+
## Development
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
cd mcp
|
|
67
|
+
uv sync
|
|
68
|
+
uv run pytest
|
|
69
|
+
uv run mocapless-mcp # stdio server
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Environment: `MOCAPLESS_API_KEY` (required), `MOCAPLESS_API_BASE` (default `https://api.mocapless.com`), `MOCAPLESS_DOWNLOAD_DIR`.
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""A thin client for the Mocapless REST API.
|
|
2
|
+
|
|
3
|
+
Everything the MCP tools do goes through here, so the tools stay small and
|
|
4
|
+
the API's error language is translated once: a 402 becomes "not enough
|
|
5
|
+
credits, here is where to top up", not a status code an agent has to guess at.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from . import __version__
|
|
17
|
+
|
|
18
|
+
DEFAULT_BASE = "https://api.mocapless.com"
|
|
19
|
+
TERMINAL = {"succeeded", "failed", "cancelled"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MocaplessError(RuntimeError):
|
|
23
|
+
"""An error worth showing to the person, in their words."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _detail(response: httpx.Response) -> str:
|
|
27
|
+
try:
|
|
28
|
+
body = response.json()
|
|
29
|
+
detail = body.get("detail") if isinstance(body, dict) else None
|
|
30
|
+
if isinstance(detail, list):
|
|
31
|
+
detail = "; ".join(str(d.get("msg", d)) for d in detail)
|
|
32
|
+
if detail:
|
|
33
|
+
return str(detail)
|
|
34
|
+
except Exception:
|
|
35
|
+
pass
|
|
36
|
+
return response.text[:300] or f"HTTP {response.status_code}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class MocaplessClient:
|
|
40
|
+
def __init__(self, api_key: str | None = None, base_url: str | None = None, timeout: float = 120.0):
|
|
41
|
+
self.api_key = (api_key or os.environ.get("MOCAPLESS_API_KEY") or "").strip()
|
|
42
|
+
self.base_url = (base_url or os.environ.get("MOCAPLESS_API_BASE") or DEFAULT_BASE).rstrip("/")
|
|
43
|
+
if not self.api_key:
|
|
44
|
+
raise MocaplessError(
|
|
45
|
+
"MOCAPLESS_API_KEY is not set. Create a key at https://mocapless.com/account "
|
|
46
|
+
"(API keys) and put it in the MCP server's environment."
|
|
47
|
+
)
|
|
48
|
+
self._http = httpx.Client(
|
|
49
|
+
base_url=self.base_url,
|
|
50
|
+
headers={"X-API-Key": self.api_key, "User-Agent": f"mocapless-mcp/{__version__}"},
|
|
51
|
+
timeout=timeout,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# ------------------------------------------------------------- transport #
|
|
55
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
56
|
+
try:
|
|
57
|
+
response = self._http.request(method, path, **kwargs)
|
|
58
|
+
except httpx.HTTPError as exc:
|
|
59
|
+
raise MocaplessError(f"Could not reach Mocapless at {self.base_url}: {exc}") from exc
|
|
60
|
+
|
|
61
|
+
if response.status_code == 401:
|
|
62
|
+
raise MocaplessError("Mocapless rejected the API key. Check MOCAPLESS_API_KEY.")
|
|
63
|
+
if response.status_code == 402:
|
|
64
|
+
raise MocaplessError(
|
|
65
|
+
f"Not enough credits: {_detail(response)} Top up at https://mocapless.com/pricing."
|
|
66
|
+
)
|
|
67
|
+
if response.status_code == 429:
|
|
68
|
+
raise MocaplessError("Rate limit reached; wait a moment and try again.")
|
|
69
|
+
if response.status_code >= 400:
|
|
70
|
+
raise MocaplessError(_detail(response))
|
|
71
|
+
if response.status_code == 204 or not response.content:
|
|
72
|
+
return None
|
|
73
|
+
return response.json()
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------- account #
|
|
76
|
+
def me(self) -> dict:
|
|
77
|
+
return self._request("GET", "/v1/auth/me")
|
|
78
|
+
|
|
79
|
+
# ------------------------------------------------------------- characters #
|
|
80
|
+
def list_characters(self) -> list[dict]:
|
|
81
|
+
return self._request("GET", "/v1/assets")
|
|
82
|
+
|
|
83
|
+
def get_character(self, asset_id: str) -> dict:
|
|
84
|
+
return self._request("GET", f"/v1/assets/{asset_id}")
|
|
85
|
+
|
|
86
|
+
def upload_character(self, path: str | Path, *, wait: bool = True, timeout: float = 900.0) -> dict:
|
|
87
|
+
file = Path(path).expanduser()
|
|
88
|
+
if not file.is_file():
|
|
89
|
+
raise MocaplessError(f"No such file: {file}")
|
|
90
|
+
if file.suffix.lower() not in (".fbx", ".glb", ".gltf", ".dae"):
|
|
91
|
+
raise MocaplessError("Upload an FBX, GLB, GLTF or DAE file.")
|
|
92
|
+
with file.open("rb") as handle:
|
|
93
|
+
asset = self._request(
|
|
94
|
+
"POST", "/v1/assets", files={"file": (file.name, handle, "application/octet-stream")}
|
|
95
|
+
)
|
|
96
|
+
if not wait:
|
|
97
|
+
return asset
|
|
98
|
+
return self.wait_character(asset["id"], timeout=timeout)
|
|
99
|
+
|
|
100
|
+
def wait_character(self, asset_id: str, *, timeout: float = 900.0, poll: float = 2.0) -> dict:
|
|
101
|
+
deadline = time.monotonic() + timeout
|
|
102
|
+
while True:
|
|
103
|
+
asset = self.get_character(asset_id)
|
|
104
|
+
if asset.get("status") == "ready":
|
|
105
|
+
return asset
|
|
106
|
+
if asset.get("status") == "failed":
|
|
107
|
+
raise MocaplessError(f"The character could not be used: {asset.get('error') or 'unknown error'}")
|
|
108
|
+
if time.monotonic() > deadline:
|
|
109
|
+
raise MocaplessError("Timed out waiting for the character to be processed.")
|
|
110
|
+
time.sleep(poll)
|
|
111
|
+
|
|
112
|
+
# -------------------------------------------------------------- generation #
|
|
113
|
+
def plan(self, prompt: str, *, engine: str = "auto", fps: int = 30) -> dict:
|
|
114
|
+
return self._request("POST", "/v1/plan", json={"prompt": prompt, "engine": engine, "fps": fps})
|
|
115
|
+
|
|
116
|
+
def generate(
|
|
117
|
+
self,
|
|
118
|
+
prompt: str,
|
|
119
|
+
*,
|
|
120
|
+
asset_id: str | None = None,
|
|
121
|
+
formats: list[str] | None = None,
|
|
122
|
+
loop: bool | None = None,
|
|
123
|
+
seed: int = 0,
|
|
124
|
+
engine: str = "auto",
|
|
125
|
+
samples: int = 1,
|
|
126
|
+
) -> dict:
|
|
127
|
+
body: dict[str, Any] = {
|
|
128
|
+
"prompt": prompt,
|
|
129
|
+
"formats": formats or ["fbx", "glb"],
|
|
130
|
+
"seed": seed,
|
|
131
|
+
"engine": engine,
|
|
132
|
+
"samples": samples,
|
|
133
|
+
}
|
|
134
|
+
if asset_id:
|
|
135
|
+
body["asset_id"] = asset_id
|
|
136
|
+
if loop is not None:
|
|
137
|
+
body["loop"] = loop
|
|
138
|
+
return self._request("POST", "/v1/generate", json=body)
|
|
139
|
+
|
|
140
|
+
def job(self, job_id: str) -> dict:
|
|
141
|
+
return self._request("GET", f"/v1/jobs/{job_id}")
|
|
142
|
+
|
|
143
|
+
def jobs(self, limit: int = 20) -> list[dict]:
|
|
144
|
+
return self._request("GET", f"/v1/jobs?limit={int(limit)}")
|
|
145
|
+
|
|
146
|
+
def wait_job(self, job_id: str, *, timeout: float = 900.0, poll: float = 2.0) -> dict:
|
|
147
|
+
"""Poll until the job is finished. Backs off to five seconds."""
|
|
148
|
+
deadline = time.monotonic() + timeout
|
|
149
|
+
wait = poll
|
|
150
|
+
while True:
|
|
151
|
+
job = self.job(job_id)
|
|
152
|
+
if job.get("status") in TERMINAL:
|
|
153
|
+
return job
|
|
154
|
+
if time.monotonic() > deadline:
|
|
155
|
+
raise MocaplessError(f"Timed out after {timeout:.0f}s; the job {job_id} is still {job.get('stage')}.")
|
|
156
|
+
time.sleep(wait)
|
|
157
|
+
wait = min(wait * 1.5, 5.0)
|
|
158
|
+
|
|
159
|
+
def share(self, job_id: str) -> dict:
|
|
160
|
+
return self._request("POST", f"/v1/jobs/{job_id}/share")
|
|
161
|
+
|
|
162
|
+
# --------------------------------------------------------------- downloads #
|
|
163
|
+
def download(self, job: dict, directory: str | Path, kinds: tuple[str, ...] = ("fbx", "glb", "bvh")) -> dict[str, str]:
|
|
164
|
+
"""Save a finished job's deliverables; returns kind -> absolute path."""
|
|
165
|
+
target = Path(directory).expanduser()
|
|
166
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
saved: dict[str, str] = {}
|
|
168
|
+
for output in job.get("outputs") or []:
|
|
169
|
+
kind = output.get("kind")
|
|
170
|
+
if kind not in kinds:
|
|
171
|
+
continue
|
|
172
|
+
url = output["url"]
|
|
173
|
+
path = target / f"{job['id'][:8]}.{kind}"
|
|
174
|
+
try:
|
|
175
|
+
# Signed storage URLs need no key; the API's own file route
|
|
176
|
+
# accepts ours. Sending it either way is harmless.
|
|
177
|
+
with self._http.stream("GET", url, headers={"X-API-Key": self.api_key}, timeout=600.0) as response:
|
|
178
|
+
response.raise_for_status()
|
|
179
|
+
with path.open("wb") as handle:
|
|
180
|
+
for chunk in response.iter_bytes():
|
|
181
|
+
handle.write(chunk)
|
|
182
|
+
except httpx.HTTPError as exc:
|
|
183
|
+
raise MocaplessError(f"Could not download the {kind}: {exc}") from exc
|
|
184
|
+
saved[kind] = str(path.resolve())
|
|
185
|
+
return saved
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""The MCP server: Mocapless as a set of tools for an agent.
|
|
2
|
+
|
|
3
|
+
Runs over stdio, which is what Claude Desktop, Claude Code, Cursor and
|
|
4
|
+
Windsurf speak. One environment variable, MOCAPLESS_API_KEY, is the whole
|
|
5
|
+
configuration. The tools are deliberately few and high-level - "generate an
|
|
6
|
+
animation and give me the files" is one call, because that is the sentence
|
|
7
|
+
a person says.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Annotated, Any
|
|
14
|
+
|
|
15
|
+
from mcp.server.fastmcp import FastMCP
|
|
16
|
+
from pydantic import Field
|
|
17
|
+
|
|
18
|
+
from .client import MocaplessClient, MocaplessError
|
|
19
|
+
|
|
20
|
+
mcp = FastMCP(
|
|
21
|
+
"Mocapless",
|
|
22
|
+
instructions=(
|
|
23
|
+
"Mocapless turns a sentence into a character animation on the user's own "
|
|
24
|
+
"rigged character. Use plan_motion to preview the beats and the price for "
|
|
25
|
+
"free; generate_animation to make the clip (it waits and downloads the "
|
|
26
|
+
"files by default). Prompts work best in plain present-tense English that "
|
|
27
|
+
"says what the body does, in order: 'A person limps forward, stops, looks "
|
|
28
|
+
"over his shoulder, then sits down.' Up to 30 seconds per clip."
|
|
29
|
+
),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
_client: MocaplessClient | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def client() -> MocaplessClient:
|
|
36
|
+
global _client
|
|
37
|
+
if _client is None:
|
|
38
|
+
_client = MocaplessClient()
|
|
39
|
+
return _client
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def reset_client() -> None:
|
|
43
|
+
global _client
|
|
44
|
+
_client = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _default_dir(job_id: str) -> Path:
|
|
48
|
+
base = os.environ.get("MOCAPLESS_DOWNLOAD_DIR") or str(Path.home() / "mocapless")
|
|
49
|
+
return Path(base).expanduser() / job_id[:8]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _job_summary(job: dict) -> dict[str, Any]:
|
|
53
|
+
return {
|
|
54
|
+
"job_id": job.get("id"),
|
|
55
|
+
"status": job.get("status"),
|
|
56
|
+
"stage": job.get("stage"),
|
|
57
|
+
"prompt": job.get("prompt"),
|
|
58
|
+
"engine": job.get("engine"),
|
|
59
|
+
"fallback_from": job.get("fallbackFrom"),
|
|
60
|
+
"frames": job.get("frames"),
|
|
61
|
+
"duration_seconds": job.get("durationSeconds"),
|
|
62
|
+
"credits_cost": job.get("creditsCost"),
|
|
63
|
+
"credits_refunded": job.get("creditsRefunded"),
|
|
64
|
+
"error": job.get("error"),
|
|
65
|
+
"outputs": {o["kind"]: o["url"] for o in (job.get("outputs") or [])},
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _character_summary(asset: dict) -> dict[str, Any]:
|
|
70
|
+
return {
|
|
71
|
+
"character_id": asset.get("id"),
|
|
72
|
+
"name": asset.get("filename"),
|
|
73
|
+
"status": asset.get("status"),
|
|
74
|
+
"bones": asset.get("boneCount"),
|
|
75
|
+
"skeleton_mapped": asset.get("coverage"),
|
|
76
|
+
"auto_rigged": asset.get("autoRigged", False),
|
|
77
|
+
"error": asset.get("error"),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# --------------------------------------------------------------------------- #
|
|
82
|
+
# tools
|
|
83
|
+
# --------------------------------------------------------------------------- #
|
|
84
|
+
def account() -> dict[str, Any]:
|
|
85
|
+
"""Who is signed in and how many credits are left. One credit is roughly one second of animation."""
|
|
86
|
+
me = client().me()
|
|
87
|
+
return {"email": me.get("email"), "credits": me.get("credits"), "plan": me.get("plan")}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def list_characters() -> list[dict[str, Any]]:
|
|
91
|
+
"""The characters in the account, newest first. New accounts have a demo mannequin ready to animate."""
|
|
92
|
+
return [_character_summary(a) for a in client().list_characters()]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def upload_character(
|
|
96
|
+
path: Annotated[str, Field(description="Local path to an FBX, GLB, GLTF or DAE. Rigged or not - an un-rigged character gets a skeleton built for it.")],
|
|
97
|
+
) -> dict[str, Any]:
|
|
98
|
+
"""Upload a character and wait until its skeleton has been read (seconds for a normal file, a minute or two for a very heavy one). Returns the character id to animate."""
|
|
99
|
+
return _character_summary(client().upload_character(path))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def plan_motion(
|
|
103
|
+
prompt: Annotated[str, Field(description="What the character should do, in plain English. Sequences are fine: 'walks forward, then waves'.")],
|
|
104
|
+
) -> dict[str, Any]:
|
|
105
|
+
"""Free preview of what generate_animation would do for this prompt: the beats, the engine, the price in credits and the current balance. Nothing is generated or charged."""
|
|
106
|
+
plan = client().plan(prompt)
|
|
107
|
+
script = plan.get("script") or {}
|
|
108
|
+
return {
|
|
109
|
+
"engine": plan.get("engine"),
|
|
110
|
+
"cost_credits": plan.get("cost"),
|
|
111
|
+
"balance_credits": plan.get("balance"),
|
|
112
|
+
"affordable": plan.get("affordable"),
|
|
113
|
+
"duration_seconds": script.get("duration"),
|
|
114
|
+
"beats": script.get("beats") or [],
|
|
115
|
+
"segments": [
|
|
116
|
+
{"primitive": s.get("primitive"), "seconds": s.get("seconds"), "style": s.get("style")}
|
|
117
|
+
for s in script.get("segments") or []
|
|
118
|
+
],
|
|
119
|
+
"notes": script.get("notes") or [],
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def generate_animation(
|
|
124
|
+
prompt: Annotated[str, Field(description="What the character should do, in plain English, in order. Best as one to four short present-tense sentences about the body: 'A person sneaks forward on tiptoe, then leaps sideways and rolls.'")],
|
|
125
|
+
character_id: Annotated[str | None, Field(description="A character id from list_characters or upload_character. Omit to use the most recent ready character.")] = None,
|
|
126
|
+
formats: Annotated[list[str], Field(description="Any of fbx, glb, bvh.")] = ["fbx", "glb"],
|
|
127
|
+
loop: Annotated[bool, Field(description="Make the clip loop seamlessly (for idles, walks, dances).")] = False,
|
|
128
|
+
quality: Annotated[str, Field(description="'standard' (one take) or 'best' (two takes, keeps the steadier one; same credits, a little slower).")] = "standard",
|
|
129
|
+
wait: Annotated[bool, Field(description="Wait for the result (usually 30-90 seconds). If false, returns the job id to check with get_job.")] = True,
|
|
130
|
+
download_dir: Annotated[str | None, Field(description="Where to save the files. Default: ~/mocapless/<job>/ (or MOCAPLESS_DOWNLOAD_DIR).")] = None,
|
|
131
|
+
) -> dict[str, Any]:
|
|
132
|
+
"""Generate an animation from a sentence on the user's character, wait for it, and download the files. Charges credits (see plan_motion first if the price matters); a failed job is refunded automatically."""
|
|
133
|
+
if quality not in ("standard", "best"):
|
|
134
|
+
raise MocaplessError("quality must be 'standard' or 'best'")
|
|
135
|
+
c = client()
|
|
136
|
+
job = c.generate(
|
|
137
|
+
prompt,
|
|
138
|
+
asset_id=character_id,
|
|
139
|
+
formats=[f.lower() for f in formats],
|
|
140
|
+
loop=loop,
|
|
141
|
+
samples=2 if quality == "best" else 1,
|
|
142
|
+
)
|
|
143
|
+
if not wait:
|
|
144
|
+
return _job_summary(job)
|
|
145
|
+
job = c.wait_job(job["id"])
|
|
146
|
+
summary = _job_summary(job)
|
|
147
|
+
if job.get("status") == "succeeded":
|
|
148
|
+
summary["files"] = c.download(job, download_dir or _default_dir(job["id"]))
|
|
149
|
+
elif job.get("status") == "failed":
|
|
150
|
+
summary["hint"] = "The credits for this job were refunded." if job.get("creditsRefunded") else None
|
|
151
|
+
return summary
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def get_job(
|
|
155
|
+
job_id: Annotated[str, Field(description="A job id from generate_animation.")],
|
|
156
|
+
download_dir: Annotated[str | None, Field(description="If set and the job succeeded, download the files here.")] = None,
|
|
157
|
+
) -> dict[str, Any]:
|
|
158
|
+
"""Status of a generation, and its files once it has finished."""
|
|
159
|
+
job = client().job(job_id)
|
|
160
|
+
summary = _job_summary(job)
|
|
161
|
+
if download_dir and job.get("status") == "succeeded":
|
|
162
|
+
summary["files"] = client().download(job, download_dir)
|
|
163
|
+
return summary
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def recent_animations(
|
|
167
|
+
limit: Annotated[int, Field(description="How many, newest first.", ge=1, le=50)] = 10,
|
|
168
|
+
) -> list[dict[str, Any]]:
|
|
169
|
+
"""The account's recent generations."""
|
|
170
|
+
return [_job_summary(j) for j in client().jobs(limit)]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def share_link(
|
|
174
|
+
job_id: Annotated[str, Field(description="A finished job id.")],
|
|
175
|
+
) -> dict[str, Any]:
|
|
176
|
+
"""A public link to watch the animation in the browser - the clip only, not the files."""
|
|
177
|
+
return client().share(job_id)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
for tool in (account, list_characters, upload_character, plan_motion, generate_animation, get_job, recent_animations, share_link):
|
|
181
|
+
mcp.add_tool(tool)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@mcp.prompt()
|
|
185
|
+
def animate(what: str) -> str:
|
|
186
|
+
"""Generate an animation for the current character from a description."""
|
|
187
|
+
return (
|
|
188
|
+
f"Use Mocapless to animate the user's character: {what}\n"
|
|
189
|
+
"First call plan_motion to check the beats and the price. If it is affordable, call "
|
|
190
|
+
"generate_animation with the same prompt, then tell the user where the files were saved "
|
|
191
|
+
"and how long the clip is."
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def main() -> None:
|
|
196
|
+
mcp.run()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
if __name__ == "__main__":
|
|
200
|
+
main()
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "mocapless-mcp"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "MCP server for Mocapless: generate character animation from text inside Claude, Cursor and any MCP client."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "Mocapless", email = "hello@mocapless.com" }]
|
|
9
|
+
keywords = ["mcp", "animation", "text-to-motion", "fbx", "mocap"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Programming Language :: Python :: 3",
|
|
12
|
+
"Topic :: Multimedia :: Graphics :: 3D Modeling",
|
|
13
|
+
]
|
|
14
|
+
dependencies = [
|
|
15
|
+
"mcp>=1.10,<2",
|
|
16
|
+
"httpx>=0.27",
|
|
17
|
+
"pydantic>=2.5",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://mocapless.com"
|
|
22
|
+
Documentation = "https://mocapless.com/developers"
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
mocapless-mcp = "mocapless_mcp.server:main"
|
|
26
|
+
|
|
27
|
+
[dependency-groups]
|
|
28
|
+
dev = ["pytest>=8"]
|
|
29
|
+
|
|
30
|
+
[build-system]
|
|
31
|
+
requires = ["hatchling"]
|
|
32
|
+
build-backend = "hatchling.build"
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["mocapless_mcp"]
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
testpaths = ["tests"]
|