motionmcp-sdk 0.1.0__py3-none-any.whl

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.
motionmcp/__init__.py ADDED
@@ -0,0 +1,76 @@
1
+ """motionmcp — Python SDK for the Motion Model Context Protocol (MMCP).
2
+
3
+ Build a vendor-neutral motion-generation HTTP server in ~30 lines:
4
+
5
+ from motionmcp import Backbone, ModelSpec, GenerateRequest, MotionResult, serve
6
+
7
+ class MyBackbone(Backbone):
8
+ def capabilities(self) -> ModelSpec:
9
+ return ModelSpec(
10
+ id="my-model",
11
+ fps=30.0,
12
+ supports_retargeting=False,
13
+ supported_constraints=["pose_keyframe"],
14
+ canonical_skeleton=load_skeleton(),
15
+ )
16
+
17
+ async def generate(self, req: GenerateRequest) -> MotionResult:
18
+ ...
19
+ return MotionResult(rotations=..., root_translations=...)
20
+
21
+ if __name__ == "__main__":
22
+ serve(MyBackbone())
23
+ """
24
+
25
+ from .backbone import Backbone, ModelSpec, MotionResult
26
+ from .errors import ProtocolError
27
+ from .protocol import (
28
+ PROTOCOL_VERSION,
29
+ SUPPORTED_CONSTRAINTS,
30
+ SUPPORTED_GUIDANCE_TYPES,
31
+ DEFAULT_LIMITS,
32
+ )
33
+ from .schemas import (
34
+ Constraint,
35
+ EffectorTargetConstraint,
36
+ GenerateRequest,
37
+ Guidance,
38
+ Joint,
39
+ Options,
40
+ PoseKeyframeConstraint,
41
+ RootPathConstraint,
42
+ Segment,
43
+ Skeleton,
44
+ TextSegment,
45
+ Timing,
46
+ UnconditionedSegment,
47
+ )
48
+ from .server import build_app, serve
49
+
50
+ __all__ = [
51
+ "Backbone",
52
+ "Constraint",
53
+ "DEFAULT_LIMITS",
54
+ "EffectorTargetConstraint",
55
+ "GenerateRequest",
56
+ "Guidance",
57
+ "Joint",
58
+ "ModelSpec",
59
+ "MotionResult",
60
+ "Options",
61
+ "PROTOCOL_VERSION",
62
+ "PoseKeyframeConstraint",
63
+ "ProtocolError",
64
+ "RootPathConstraint",
65
+ "SUPPORTED_CONSTRAINTS",
66
+ "SUPPORTED_GUIDANCE_TYPES",
67
+ "Segment",
68
+ "Skeleton",
69
+ "TextSegment",
70
+ "Timing",
71
+ "UnconditionedSegment",
72
+ "build_app",
73
+ "serve",
74
+ ]
75
+
76
+ __version__ = "0.1.0"
motionmcp/backbone.py ADDED
@@ -0,0 +1,179 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Backbone abstraction — what users implement to plug their model into MMCP.
3
+
4
+ Two pieces:
5
+
6
+ * :class:`ModelSpec` — the per-model metadata served at ``GET /capabilities``.
7
+ * :class:`Backbone` — the ABC users subclass; defines :meth:`Backbone.capabilities`
8
+ and :meth:`Backbone.generate`.
9
+
10
+ A :class:`MotionResult` is what :meth:`Backbone.generate` returns: per-sample
11
+ quaternion rotations, root translations, and optional foot-contact masks. The
12
+ SDK encodes this to glTF for you.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import abc
18
+ import inspect
19
+ from dataclasses import dataclass, field
20
+ from typing import Awaitable, Sequence
21
+
22
+ import numpy as np
23
+ from pydantic import BaseModel, ConfigDict, Field
24
+
25
+ from .protocol import (
26
+ DEFAULT_LIMITS,
27
+ SUPPORTED_GUIDANCE_TYPES,
28
+ )
29
+ from .schemas import GenerateRequest, Skeleton
30
+
31
+
32
+ # ---- ModelSpec — published at /capabilities --------------------------------
33
+
34
+ class Limits(BaseModel):
35
+ model_config = ConfigDict(extra="forbid")
36
+ max_duration_seconds: float = float(DEFAULT_LIMITS["max_duration_seconds"])
37
+ max_num_samples: int = int(DEFAULT_LIMITS["max_num_samples"])
38
+ max_constraints_per_request: int = int(DEFAULT_LIMITS["max_constraints_per_request"])
39
+ max_prompt_length: int = int(DEFAULT_LIMITS["max_prompt_length"])
40
+ max_request_bytes: int = int(DEFAULT_LIMITS["max_request_bytes"])
41
+
42
+
43
+ class ModelSpec(BaseModel):
44
+ """Per-model entry under ``/capabilities.models[]``.
45
+
46
+ All fields except ``id``, ``fps``, and ``canonical_skeleton`` have spec
47
+ defaults — you only have to fill in what differs from the conservative
48
+ baseline.
49
+ """
50
+ model_config = ConfigDict(extra="forbid")
51
+
52
+ id: str = Field(..., min_length=1)
53
+ fps: float = Field(..., gt=0)
54
+ canonical_skeleton: Skeleton
55
+
56
+ supports_retargeting: bool = False
57
+ supports_async: bool = False
58
+
59
+ supported_constraints: list[str] = Field(default_factory=list)
60
+ supported_guidance_types: list[str] = Field(
61
+ default_factory=lambda: list(SUPPORTED_GUIDANCE_TYPES)
62
+ )
63
+ predicted_contact_joints: list[str] = Field(default_factory=list)
64
+
65
+ native_clip_seconds: float = 10.0
66
+ chunking: str = Field("none", pattern=r"^(none|stitched)$")
67
+ recommended_max_duration_seconds: float = 12.0
68
+
69
+ limits: Limits = Field(default_factory=Limits)
70
+
71
+
72
+ # ---- MotionResult — what generate() returns --------------------------------
73
+
74
+ @dataclass
75
+ class MotionResult:
76
+ """The output of :meth:`Backbone.generate`.
77
+
78
+ Shape conventions (B = num_samples, T = num_frames, J = num_joints):
79
+
80
+ * ``rotations``: ``(B, T, J, 4)`` quaternions ``(x, y, z, w)``, local-to-parent.
81
+ * ``root_translations``: ``(B, T, 3)`` world-space root positions.
82
+ * ``joint_names``: optional ``(J,)`` list. Defaults to the request skeleton's
83
+ joint order. Provide this only if your model retargeted internally and
84
+ you need to declare a different output order — the SDK will validate
85
+ that ``joint_names`` matches the request skeleton (or your declared
86
+ ``canonical_to_request`` map).
87
+ * ``foot_contacts``: optional ``{joint_name: (B, T) bool array}``. Joint
88
+ names must exist in the request skeleton.
89
+ * ``chunk_boundaries``: optional per-sample list of frame indices where
90
+ the server stitched internal chunks. Empty / None means no stitching.
91
+ * ``canonical_to_request``: optional ``{canonical_joint: request_joint}``
92
+ map echoed in the response extension. Only populate when the server
93
+ actually retargeted.
94
+ """
95
+
96
+ rotations: np.ndarray
97
+ root_translations: np.ndarray
98
+ joint_names: Sequence[str] | None = None
99
+ foot_contacts: dict[str, np.ndarray] = field(default_factory=dict)
100
+ chunk_boundaries: Sequence[Sequence[int]] | None = None
101
+ canonical_to_request: dict[str, str] | None = None
102
+
103
+ def __post_init__(self) -> None:
104
+ r = self.rotations
105
+ if r.ndim != 4 or r.shape[-1] != 4:
106
+ raise ValueError(
107
+ f"rotations must be (B, T, J, 4); got {r.shape}"
108
+ )
109
+ B, T, J, _ = r.shape
110
+ t = self.root_translations
111
+ if t.shape != (B, T, 3):
112
+ raise ValueError(
113
+ f"root_translations must be ({B}, {T}, 3); got {t.shape}"
114
+ )
115
+ for joint, mask in self.foot_contacts.items():
116
+ if mask.shape != (B, T):
117
+ raise ValueError(
118
+ f"foot_contacts[{joint!r}] must be ({B}, {T}); "
119
+ f"got {mask.shape}"
120
+ )
121
+
122
+ @property
123
+ def num_samples(self) -> int:
124
+ return int(self.rotations.shape[0])
125
+
126
+ @property
127
+ def num_frames(self) -> int:
128
+ return int(self.rotations.shape[1])
129
+
130
+ @property
131
+ def num_joints(self) -> int:
132
+ return int(self.rotations.shape[2])
133
+
134
+
135
+ # ---- Backbone ABC ----------------------------------------------------------
136
+
137
+ class Backbone(abc.ABC):
138
+ """The contract a user implements to plug their motion model into MMCP.
139
+
140
+ Subclasses MUST implement:
141
+
142
+ * :meth:`capabilities` — returns a :class:`ModelSpec` describing the model.
143
+ * :meth:`generate` — runs the model on a validated :class:`GenerateRequest`
144
+ and returns a :class:`MotionResult`.
145
+
146
+ :meth:`generate` may be ``async def`` or a regular function — the SDK
147
+ handles both. Async is preferred when the underlying model has any I/O or
148
+ benefits from yielding (queueing on a GPU worker, awaiting a remote call).
149
+
150
+ Raise :class:`~motionmcp.errors.ProtocolError` (or one of its convenience
151
+ constructors in :mod:`motionmcp.errors`) for protocol-level failures —
152
+ the SDK converts them to the standard error envelope. Any other exception
153
+ becomes a generic ``500 internal_error``.
154
+ """
155
+
156
+ @abc.abstractmethod
157
+ def capabilities(self) -> ModelSpec:
158
+ """Return the per-model entry that goes under ``/capabilities.models[]``."""
159
+
160
+ @abc.abstractmethod
161
+ def generate(self, request: GenerateRequest) -> MotionResult | Awaitable[MotionResult]:
162
+ """Run motion generation for one validated request.
163
+
164
+ May be implemented as either ``def`` or ``async def``.
165
+ """
166
+
167
+ # Optional hook: called once at server startup. Use it to load model
168
+ # weights, allocate GPU buffers, warm caches.
169
+ def setup(self) -> None: # pragma: no cover - default no-op
170
+ return
171
+
172
+ # Optional hook: called once at server shutdown.
173
+ def teardown(self) -> None: # pragma: no cover - default no-op
174
+ return
175
+
176
+
177
+ def is_async_generate(backbone: Backbone) -> bool:
178
+ """Return True if the backbone's ``generate`` is an async coroutine function."""
179
+ return inspect.iscoroutinefunction(backbone.generate)
motionmcp/errors.py ADDED
@@ -0,0 +1,122 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """MMCP error envelope and protocol-level exceptions.
3
+
4
+ The wire format is a single shape::
5
+
6
+ {"error": {"code": "...", "message": "...", "details": {...}}}
7
+
8
+ Backbone code raises :class:`ProtocolError` (or one of the typed subclasses)
9
+ to signal a failure; the SDK's FastAPI layer maps it to the right HTTP status
10
+ via :data:`STATUS_FOR` and serialises the envelope.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+
18
+ # Code → HTTP status. The set is closed at v1.0; new codes go in minor versions.
19
+ STATUS_FOR: dict[str, int] = {
20
+ "schema_validation": 422,
21
+ "unknown_model": 400,
22
+ "retargeting_unsupported": 400,
23
+ "unsupported_constraint": 400,
24
+ "unknown_joint": 400,
25
+ "invalid_skeleton": 400,
26
+ "frame_out_of_range": 400,
27
+ "invalid_options": 400,
28
+ "version_unsupported": 400,
29
+ "unauthorized": 401,
30
+ "forbidden": 403,
31
+ "payload_too_large": 413,
32
+ "rate_limited": 429,
33
+ "internal_error": 500,
34
+ "resource_exhausted": 503,
35
+ "model_unavailable": 503,
36
+ "timeout": 504,
37
+ }
38
+
39
+
40
+ class ProtocolError(Exception):
41
+ """Raised by handlers to signal a protocol-level failure.
42
+
43
+ The FastAPI app catches this and serialises it to the MMCP error envelope.
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ code: str,
49
+ message: str,
50
+ details: dict[str, Any] | None = None,
51
+ ):
52
+ if code not in STATUS_FOR:
53
+ raise ValueError(f"unknown protocol error code: {code!r}")
54
+ super().__init__(f"{code}: {message}")
55
+ self.code = code
56
+ self.message = message
57
+ self.details = details or {}
58
+
59
+ @property
60
+ def status_code(self) -> int:
61
+ return STATUS_FOR[self.code]
62
+
63
+ def to_envelope(self) -> dict[str, Any]:
64
+ return {
65
+ "error": {
66
+ "code": self.code,
67
+ "message": self.message,
68
+ "details": self.details,
69
+ }
70
+ }
71
+
72
+
73
+ # ---- Convenience constructors for common cases ---------------------------
74
+
75
+ def unknown_model(model_id: str, available: list[str]) -> ProtocolError:
76
+ return ProtocolError(
77
+ "unknown_model",
78
+ f"model {model_id!r} is not registered with this server",
79
+ details={"available_models": available},
80
+ )
81
+
82
+
83
+ def unsupported_constraint(
84
+ constraint_type: str, supported: list[str]
85
+ ) -> ProtocolError:
86
+ return ProtocolError(
87
+ "unsupported_constraint",
88
+ f"constraint type {constraint_type!r} is not supported by this model",
89
+ details={"supported_constraints": supported},
90
+ )
91
+
92
+
93
+ def unknown_joint(joint: str, skeleton_joints: list[str]) -> ProtocolError:
94
+ return ProtocolError(
95
+ "unknown_joint",
96
+ f"joint {joint!r} is not in the request skeleton",
97
+ details={"skeleton_joints": skeleton_joints},
98
+ )
99
+
100
+
101
+ def frame_out_of_range(frame: int, total_frames: int) -> ProtocolError:
102
+ return ProtocolError(
103
+ "frame_out_of_range",
104
+ f"frame {frame} is outside [0, {total_frames - 1}]",
105
+ details={"frame": frame, "total_frames": total_frames},
106
+ )
107
+
108
+
109
+ def retargeting_unsupported() -> ProtocolError:
110
+ return ProtocolError(
111
+ "retargeting_unsupported",
112
+ "this model does not support retargeting; send the canonical "
113
+ "skeleton verbatim from /capabilities",
114
+ )
115
+
116
+
117
+ def version_unsupported(requested: str, supported_majors: list[str]) -> ProtocolError:
118
+ return ProtocolError(
119
+ "version_unsupported",
120
+ f"protocol_version {requested!r} is not supported by this server",
121
+ details={"supported_majors": supported_majors},
122
+ )