Client
motionmcp.client is the other side of the wire: ask an MMCP server for
a motion, read back the glTF it answers with. It works against any
compliant server, not just ones built with this SDK.
from motionmcp import client
caps = client.get_capabilities("http://127.0.0.1:8000") # cached per (url, access_token)
model = client.pick_model(caps, wanted="kimodo-soma-rp") # a model id from the capabilities
segments = client.model_supported_segments(model) # which segment types it accepts
doc = client.generate("http://127.0.0.1:8000", request_body,
access_token=None, # Bearer token, if the server wants one
headers={"X-My-App": "1.0"}, # merged after the standard headers
on_progress=print) # 202 + polling on the async path
samples = client.parse_gltf_samples(doc) # one motion_data dict per sample
Three modules, on purpose small:
| Module | What it does | Depends on |
|---|---|---|
motionmcp.client.http | Speaks the protocol: capabilities, probing, generation. | Standard library |
motionmcp.client.glb | Unpacks a binary glTF (GLB) answer into a glTF JSON document. | Standard library |
motionmcp.client.gltf_parser | Turns the glTF 2.0 document into plain arrays. | numpy |
Everything public is re-exported from motionmcp.client.
Install
pip install motionmcp-sdk
No extras. The base install is numpy plus the client — the server stack
(pydantic, FastAPI, uvicorn) lives behind [server], see
Install →.
The HTTP layer uses urllib from the standard library — no requests,
no httpx. The client is built to run inside interpreters embedded in
DCC applications (Blender, Maya, Houdini, …), whose site-packages you
don't own and which rarely ship either library.
Capabilities & model choice
from motionmcp import client
caps = client.get_capabilities(url, access_token=token)
-
get_capabilities(server_url, timeout=10.0, use_cache=True, access_token=None)—GET /capabilities, parsed. Cached per(server_url, access_token)for the life of the process, so a local server and a cloud one, or two accounts against the same server, never collide.use_cache=Falsebypasses the cache. -
cached_capabilities(server_url, access_token=None)— the cached response, orNone. Never touches the network, so a UI can consult it on a repaint without blocking. -
clear_capabilities_cache()— forget every cached response. Call it after the user changes the server URL or restarts the server. -
pick_model(caps, wanted=None)— themodels[]entry whoseidmatcheswanted(case-insensitive), or the first entry whenwantedis empty or unknown. The same setting travels between servers, so an unknown name falls back instead of failing. ReturnsNoneonly when the server lists no models. -
model_supported_segments(model_info)— one model's advertisedsupported_segments, lower-cased and de-duplicated;[]when absent. Use it to decide whether to send aposesegment or fall back totext. See Segments →. -
probe_server(server_url, *, timeout=5.0, access_token=None)— a liveness check that never raises. Always bypasses the cache and returns aProbeResult:Field Type Meaning okboolTrueonly whenstatus == "online"statusstr"online","unreachable","auth_required","http_error"or"bad_response"messagestrHuman-readable summary capabilitiesdict | NoneThe parsed capabilities when online -
retarget_state(server_url, timeout=5.0, access_token=None)—"ready","unavailable"or"unknown", read fromGET /health."unknown"means send the request and let the server answer — never a reason to refuse.
result = client.probe_server(url, access_token=token)
if result.status == "auth_required":
... # ask the user for a token
elif result.ok:
model = client.pick_model(result.capabilities, wanted=settings.model)
Generate
doc = client.generate(server_url, request_body,
timeout=600.0, access_token=None,
on_progress=None, headers=None)
request_body is a plain dict in the
GenerateRequest shape; generate()
POSTs it to /generate and returns the glTF 2.0 document as a dict.
- Sync and async. A
200 OKis returned directly. A202 Acceptedis followed to itsLocationand polled, honouringRetry-After, until the job finishes ortimeoutruns out. See Async jobs →. on_progress— called with a status string (elapsed seconds) on each poll cycle, for surfacing progress in a UI.access_token— sent asAuthorization: Bearer …on every request, including polling.headers— extra request headers, merged in after the standard ones. This is how an embedding application identifies itself. They ride on the request that starts a generation and on nothing else — not on/capabilities,/healthor job polling.Authorizationderived fromaccess_tokenwins over a same-named entry here.- Binary glTF. A server may answer with
model/gltf-binary(GLB) instead of JSON, on the synchronous answer or after polling. The client unpacks it withglb_to_gltfinto the same glTF JSON document the JSON path returns, so callers never branch on the content type.
Resuming a poll after a token refresh
When the server answers 202, the job is already running. If polling it
then fails — most often a 401 because the access token expired
mid-job — calling generate() again would start a second job. Every
MmcpError from generate() names its phase in details["phase"]:
"generate" when the POST itself failed, "poll" when the job was
accepted and polling it failed. A poll error also carries
details["location"], the job path relative to the server URL, and
poll_job(server_url, location, *, retry_after=2.0, timeout=600.0, access_token=None, on_progress=None) picks the same job up again:
try:
return client.generate(url, request_body, access_token=token)
except client.MmcpError as e:
if e.code == "auth_required" and e.details.get("phase") == "poll":
token = refresh()
return client.poll_job(url, e.details["location"], access_token=token)
raise
poll_job behaves as the polling inside generate() does: at least
0.5 s between polls, Retry-After honoured, GLB unpacked, timeout
counted from the start of polling. Its own errors carry the same
phase and location, so the retry can repeat.
glb_to_gltf(data) is public too, for GLB bytes you got some other way:
from motionmcp.client import glb_to_gltf
doc = glb_to_gltf(open("motion.glb", "rb").read())
See Response → for the document itself.
A video as the prompt (1.2)
A video_reference
segment carries its video by https URL or inline as base64.
video_source(path=None, *, url=None, media_type=None) builds its
video object from either, with the standard library alone (it reads and
encodes the file; it never decodes the video):
from motionmcp.client import video_source, model_supported_segments
model = client.pick_model(caps)
if "video_reference" in model_supported_segments(model):
video = video_source("dance.mov") # {"data": "<base64>", "media_type": "video/quicktime"}
# or: video_source(url="https://cdn.example.com/dance.mp4") -> {"url": ...}
request_body["segments"] = [
{"type": "text", "prompt": "a person walks in", "duration_frames": 60},
{"type": "video_reference", "duration_frames": 120, "video": video,
"start_s": 2.0, "end_s": 6.0},
]
The media type comes from the extension (.mp4 / .m4v, .mov, .webm)
unless media_type is passed; anything else raises ValueError, as does
an empty file or a URL the server would refuse (not https, no host,
user:pw@, whitespace, a bad port). Pass max_bytes= (the model's
limits.max_video_bytes) to refuse a file over it before reading it.
data must be standard base64, padded, on one line: the alphabet
A–Z a–z 0–9 + / with = padding, and no line breaks. video_source
and Python's base64.b64encode produce exactly that. The GNU base64
command wraps its output at 76 columns by default, which the server
refuses; use base64 -w0 clip.mp4 (on macOS, base64 -i clip.mp4 does
not wrap). URL-safe base64 (- and _) is refused too. Check limits.max_video_bytes before
sending a file inline: a server refuses one over it with
413 payload_too_large, and the whole body must still fit
limits.max_request_bytes (base64 is a third bigger than the file).
Building a motion_reference from a rig, and more on video references,
is in Building reference segments →.
Errors
Every transport or server-side failure raises MmcpError (a
RuntimeError). probe_server is the only call that never raises.
from motionmcp.client import MmcpError
try:
doc = client.generate(url, request_body, access_token=token)
except MmcpError as exc:
print(exc.code, exc.details.get("status"), exc.details.get("request_id"))
-
code— the server'serror.codewhen its answer carries the MMCP error envelope (e.g."retargeting_unsupported"); otherwise a client-side code:"connection_failed","http_error","bad_response","timeout"or"transport". HTTP 401 is always"auth_required", whatever the envelope says. -
details— the envelope's owndetails, plus, on an HTTP failure:Key From statusThe HTTP status (unless the server already named one) retry_afterThe Retry-Afterheader, in seconds, when numericrequest_idX-Request-ID, orX-Job-Idif that is absentand, on anything
generate()orpoll_job()raises:Key From phase"generate"(thePOST) or"poll"(polling an accepted job)locationThe polled job path, relative to the server URL; "poll"only
See Errors reference → for the codes a server can send.
Parsing
from motionmcp.client import parse_gltf, parse_gltf_samples, xyzw_to_rotmat
parse_gltf(doc)— the first sample as amotion_datadict.parse_gltf_samples(doc)— onemotion_datadict peranimations[]entry, i.e. per sample whennum_samples > 1. RaisesValueErrorif the document has no animations.xyzw_to_rotmat(xyzw)—[N, 4]XYZW quaternions to[N, 3, 3]rotation matrices.
Each motion_data dict holds:
| Key | Shape / type | |
|---|---|---|
local_rot_mats | ndarray [T, J, 3, 3] | Local joint rotations |
posed_joints | ndarray [T, J, 3] | World positions, meters |
fps | float | |
num_frames | int | T |
num_joints | int | J |
joint_names | list[str] | In joint-index order |
foot_contacts | ndarray [T, J] bool | From MMCP_motion; all-False for joints the server didn't report |
hierarchy | list[(name, parent or None)] | In joint_names order |
rest_positions | dict[name → (x, y, z)] | World-space rest, meters |
Missing foot contacts degrade to all-False rather than raising — the
motion matters more than the metadata.
Where to go next
- Quickstart (client) → — the same flow in raw HTTP.
- Response → — the glTF document and the
MMCP_motionextension. - Errors reference → — every error code.