Skip to main content

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:

ModuleWhat it doesDepends on
motionmcp.client.httpSpeaks the protocol: capabilities, probing, generation.Standard library
motionmcp.client.glbUnpacks a binary glTF (GLB) answer into a glTF JSON document.Standard library
motionmcp.client.gltf_parserTurns 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=False bypasses the cache.

  • cached_capabilities(server_url, access_token=None) — the cached response, or None. 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) — the models[] entry whose id matches wanted (case-insensitive), or the first entry when wanted is empty or unknown. The same setting travels between servers, so an unknown name falls back instead of failing. Returns None only when the server lists no models.

  • model_supported_segments(model_info) — one model's advertised supported_segments, lower-cased and de-duplicated; [] when absent. Use it to decide whether to send a pose segment or fall back to text. See Segments →.

  • probe_server(server_url, *, timeout=5.0, access_token=None) — a liveness check that never raises. Always bypasses the cache and returns a ProbeResult:

    FieldTypeMeaning
    okboolTrue only when status == "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 from GET /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 OK is returned directly. A 202 Accepted is followed to its Location and polled, honouring Retry-After, until the job finishes or timeout runs 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 as Authorization: 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, /health or job polling. Authorization derived from access_token wins 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 with glb_to_gltf into 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's error.code when 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 own details, plus, on an HTTP failure:

    KeyFrom
    statusThe HTTP status (unless the server already named one)
    retry_afterThe Retry-After header, in seconds, when numeric
    request_idX-Request-ID, or X-Job-Id if that is absent

    and, on anything generate() or poll_job() raises:

    KeyFrom
    phase"generate" (the POST) 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 a motion_data dict.
  • parse_gltf_samples(doc) — one motion_data dict per animations[] entry, i.e. per sample when num_samples > 1. Raises ValueError if the document has no animations.
  • xyzw_to_rotmat(xyzw) — [N, 4] XYZW quaternions to [N, 3, 3] rotation matrices.

Each motion_data dict holds:

KeyShape / type
local_rot_matsndarray [T, J, 3, 3]Local joint rotations
posed_jointsndarray [T, J, 3]World positions, meters
fpsfloat
num_framesintT
num_jointsintJ
joint_nameslist[str]In joint-index order
foot_contactsndarray [T, J] boolFrom MMCP_motion; all-False for joints the server didn't report
hierarchylist[(name, parent or None)]In joint_names order
rest_positionsdict[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​