Skip to content
Merged
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
20 changes: 11 additions & 9 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **`set_transform` refuses a non-uniform scale instead of faking it.** An O3DE
Transform holds only a uniform scale, so a scale like `[50, 50, 1]` was applied
as a uniform 50 and reported as success. It now returns
`non_uniform_scale_unsupported`. Adding the engine's Non-uniform Scale component
from editor Python does not work either (live: `AddNonUniformScaleComponent`
adds nothing, and the component is not in the add-component list), so real
non-uniform scale waits for a native gem path. The editor-Python path also no
longer resets an entity's scale to 1 when `scale` is not given, and it reads
the scale back, reporting `set_transform_failed` when it did not land.
- **`set_transform` applies non-uniform scale for real, and stops faking it.** An O3DE
Transform holds only a uniform scale, so a scale like `[50, 50, 1]` was applied as a
uniform 50 and reported as success. On AiCompanion gem API 0.5.0+ (gem 0.6.0) the
gem now applies it with the engine's Non-uniform Scale component (the editor
Python API cannot add that component; tried live), and `set_transform` sends
rotations as quaternions natively, which also removes the editor-Python detour
for rotations at a gimbal pole. On older gems a non-uniform scale is refused with
`non_uniform_scale_unsupported`. `get_transform` reports the effective scale with
`uniform_scale` and `non_uniform_scale`. The editor-Python path also no longer
resets an entity's scale to 1 when `scale` is not given, and it reads the scale
back, reporting `set_transform_failed` when it did not land.
- **Entity ids are looked up, never rebuilt, in editor scripts.** The shared
resolver tried `azlmbr.entity.EntityId(n)` first; on 26.10.0 that answers an
invalid id for every `n` (reported by the AiCompanion session), so it only ever
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

An MCP server (Model Context Protocol) that exposes Open 3D Engine (O3DE) capabilities to AI assistants. 96 tools across seven categories:
- **Capabilities tools** (`src/o3de_mcp/tools/capabilities.py`, 1 tool) — runtime detection of editor connectivity and CLI availability. Call `get_capabilities()` first to know what's available.
- **Editor tools** (`src/o3de_mcp/tools/editor.py`, 41 tools): send Python scripts to a running O3DE Editor over TCP port 4600. Covers entities, components, transforms, prefabs, levels, viewport/camera, console and CVARs, game mode, undo/redo, and persistent scripting sessions. Requires the AiCompanion and EditorPythonBindings gems active in the editor. Four of the tools (`get_scene_snapshot`, `get_entity_tree`, `get_entity`, `validate_scene`) use the AgentServer's native C++ request types instead of sending Python. `get_bus_schema_live`, `create_entity`, `set_transform` and `delete_entity` try their native request types first (`_native_bus_schema` in `tools/introspection.py`, `_native_mutation` in `tools/editor.py`) and fall back to editor Python when the gem answers `unknown_request_type` (too old to serve them) or the transport is the legacy RemoteConsole; a native refusal (validation, missing entity) is returned as an error, never retried through Python. `set_transform` uses editor Python directly for a rotation near a gimbal pole and refuses a non-uniform scale (a Transform's scale is uniform), and `get_bus_schema_live` falls back on any native failure, including an unknown bus. Fast-fails when the editor is unreachable.
- **Editor tools** (`src/o3de_mcp/tools/editor.py`, 41 tools): send Python scripts to a running O3DE Editor over TCP port 4600. Covers entities, components, transforms, prefabs, levels, viewport/camera, console and CVARs, game mode, undo/redo, and persistent scripting sessions. Requires the AiCompanion and EditorPythonBindings gems active in the editor. Four of the tools (`get_scene_snapshot`, `get_entity_tree`, `get_entity`, `validate_scene`) use the AgentServer's native C++ request types instead of sending Python. `get_bus_schema_live`, `create_entity`, `set_transform` and `delete_entity` try their native request types first (`_native_bus_schema` in `tools/introspection.py`, `_native_mutation` in `tools/editor.py`) and fall back to editor Python when the gem answers `unknown_request_type` (too old to serve them) or the transport is the legacy RemoteConsole; a native refusal (validation, missing entity) is returned as an error, never retried through Python. On gem API 0.5.0+ `set_transform` sends quaternions and non-uniform scale natively (the gem uses the Non-uniform Scale component); on older gems it uses editor Python for a rotation near a gimbal pole and refuses a non-uniform scale (a Transform's own scale is uniform), and `get_bus_schema_live` falls back on any native failure, including an unknown bus. Fast-fails when the editor is unreachable.
- **Introspection tools** (`src/o3de_mcp/tools/introspection.py`, 3 tools) — EBus schema discovery (static stub parsing and live query) plus RenderDoc frame capture.
- **Project tools** (`src/o3de_mcp/tools/project.py`, 17 tools) — wrap the O3DE CLI (`scripts/o3de.sh` / `o3de.bat`) and CMake for project creation, gem management, engine registration, builds (blocking and background), and export.
- **Asset tools** (`src/o3de_mcp/tools/assets.py`, 9 tools): Asset Processor status, asset refresh/wait and log tailing without the editor, plus per-asset readiness through the AiCompanion gem's native request types (gem 0.6.0+): `get_asset_status`, `get_asset_jobs` (with failure logs), `get_asset_processor_connection` and `wait_for_asset`, which tells a failed build from a file not registered yet.
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ The [**o3de-ai-companion-gem**](https://github.com/nickschuetz/o3de-ai-companion

Scripts are base64-encoded for safe transport and executed in the editor's embedded Python interpreter.

Besides `execute_python`, the AgentServer answers `ping`, `get_api_version`, `get_scene_snapshot`, `get_entity_tree`, `get_entity`, `validate_scene` and `get_bus_schema` natively in C++ (`get_entity` and `get_bus_schema` from gem 0.4.0), plus `create_entity`, `set_transform` and `delete_entity` from gem 0.5.0. Gem main (shipping as 0.6.0) adds the anim graph request types (`list_anim_graphs`, `get_anim_graph`, and the authoring types from `create_anim_graph` to `set_anim_graph_node`), which the animation tools call directly with no Python fallback, and the asset readiness types (`get_asset_status`, `get_asset_jobs`, `get_asset_processor_status`), which the asset tools call the same way. `get_capabilities` uses `get_api_version` to tell a real AgentServer (gem present) from a bare socket, and the snapshot tools call their request types directly, so they work even when the gem's secure mode disables `execute_python`. The three mutation tools try their native type first and send their editor-Python script only when the gem answers `Unknown request type` (or the transport is the legacy RemoteConsole, where native requests return `agent_server_required`); any other refusal is returned as an error, never retried through Python. `set_transform` goes straight to editor Python for a rotation within 0.02 degrees of a gimbal pole, which the native Euler form cannot carry losslessly, and refuses a non-uniform scale (a Transform holds only a uniform scale). `get_bus_schema_live` falls back to editor Python, then to the stub files, on any native failure, including an unknown bus. From gem 0.6.0 (API 0.4.0) every native refusal carries a `code` (`validation_failed`, `not_found`, `unavailable`, `engine_error`, `secure_mode`, `execution_failed`, `unknown_request_type`, `timeout`, `shutting_down`), which o3de-mcp relays in its failure envelope. The snapshot tools report a native failure rather than falling back.
Besides `execute_python`, the AgentServer answers `ping`, `get_api_version`, `get_scene_snapshot`, `get_entity_tree`, `get_entity`, `validate_scene` and `get_bus_schema` natively in C++ (`get_entity` and `get_bus_schema` from gem 0.4.0), plus `create_entity`, `set_transform` and `delete_entity` from gem 0.5.0. Gem main (shipping as 0.6.0) adds the anim graph request types (`list_anim_graphs`, `get_anim_graph`, and the authoring types from `create_anim_graph` to `set_anim_graph_node`), which the animation tools call directly with no Python fallback, and the asset readiness types (`get_asset_status`, `get_asset_jobs`, `get_asset_processor_status`), which the asset tools call the same way. `get_capabilities` uses `get_api_version` to tell a real AgentServer (gem present) from a bare socket, and the snapshot tools call their request types directly, so they work even when the gem's secure mode disables `execute_python`. The three mutation tools try their native type first and send their editor-Python script only when the gem answers `Unknown request type` (or the transport is the legacy RemoteConsole, where native requests return `agent_server_required`); any other refusal is returned as an error, never retried through Python. On gem API 0.5.0+ `set_transform` sends its quaternion (`rotation_quaternion`) and a non-uniform `[x, y, z]` scale natively; the gem applies non-uniform scale with the engine's Non-uniform Scale component. On older gems it converts the quaternion to Euler degrees, goes to editor Python for a rotation within 0.02 degrees of a gimbal pole (where the Euler form cannot carry it), and refuses a non-uniform scale. `get_bus_schema_live` falls back to editor Python, then to the stub files, on any native failure, including an unknown bus. From gem 0.6.0 (API 0.4.0) every native refusal carries a `code` (`validation_failed`, `not_found`, `unavailable`, `engine_error`, `secure_mode`, `execution_failed`, `unknown_request_type`, `timeout`, `shutting_down`), which o3de-mcp relays in its failure envelope. The snapshot tools report a native failure rather than falling back.

#### Connection lifecycle & timeouts

Expand Down
21 changes: 15 additions & 6 deletions docs/tool-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ Set the world transform of an entity. Only provided components are changed.
| `entity_id` | int or str | yes | Entity ID |
| `position` | list[float] | no | [x, y, z] world position |
| `rotation` | list[float] | no | [x, y, z, w] quaternion rotation (4 elements) |
| `scale` | list[float] | no | [x, y, z] scale; the three values must match (a Transform's scale is uniform) |
| `scale` | list[float] | no | [x, y, z] effective scale, each in (0, 1000]; non-uniform needs gem API 0.5.0+ (each element at least 0.01) |

Tries the gem's native `set_transform` request first (gem 0.5.0 or later,
works in secure mode). The quaternion is converted to the XYZ Euler degrees the
Expand All @@ -247,10 +247,16 @@ directly, keeps the current scale when none is given, and prints
`Transform set for entity [<id>]`. An all-zero quaternion is rejected before
either path.

An O3DE Transform holds only a uniform scale, and the Non-uniform Scale
component cannot be added from editor Python, so a `scale` whose three values
differ is refused with `non_uniform_scale_unsupported` (it used to be applied
silently as its largest value).
On AiCompanion gem API 0.5.0 and later (gem 0.6.0), the rotation goes to the gem as
a quaternion as is (no Euler conversion and no editor-Python detour at a gimbal
pole), and a non-uniform `scale` is applied natively: an O3DE Transform holds only a
uniform scale, so the gem adds the engine's Non-uniform Scale component (as the
Transform's "Add non-uniform scale" button does), sets the Transform's scale to 1 and
the component to the vector, in one undo step. A later uniform scale sets the
component back to `[1, 1, 1]`. The reply carries `scale` (the uniform scale),
`non_uniform_scale` and `effective_scale`. On older gems a non-uniform `scale` is
refused with `non_uniform_scale_unsupported` (it used to be applied silently as its
largest value), because the component cannot be added from editor Python.

### get_transform

Expand All @@ -260,7 +266,10 @@ Get the world transform of an entity.
|-------|------|----------|-------------|
| `entity_id` | int or str | yes | Entity ID |

Returns JSON: `{"position": [x,y,z], "rotation": [x,y,z,w], "scale": [x,y,z]}`
Returns JSON: `{"position": [x,y,z], "rotation": [x,y,z,w], "scale": [x,y,z],
"uniform_scale": s, "non_uniform_scale": [x,y,z] or null}`. `scale` is the effective
scale: the Transform's uniform scale times the entity's Non-uniform Scale component,
when it has one.

### set_parent

Expand Down
95 changes: 79 additions & 16 deletions src/o3de_mcp/tools/editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -669,6 +669,9 @@ def __init__(self) -> None:
self._lock_loop: asyncio.AbstractEventLoop | None = None
self._last_failure_time: float | None = None
self._protocol: int = _PROTO_UNKNOWN
# The gem's API version on this connection, cached until it closes.
self._api_version: tuple[int, ...] | None = None
self._api_version_known: bool = False

async def send_script(
self,
Expand Down Expand Up @@ -959,6 +962,9 @@ async def _detect_protocol(self, host: str, port: int, connect_timeout: float) -
return _PROTO_LEGACY

async def _close(self) -> None:
# A reconnect may reach a different gem, so forget its API version.
self._api_version = None
self._api_version_known = False
if self._writer is not None:
current_loop = None
try:
Expand Down Expand Up @@ -990,6 +996,37 @@ async def _close(self) -> None:
_pool = _EditorConnectionPool()


async def _gem_api_version() -> tuple[int, ...] | None:
"""The AiCompanion gem's API version on the current connection, or None.

None means no gem answered (the legacy RemoteConsole transport, an editor
that is down, or a reply without a version). Cached until the connection
closes, so a reconnect to a different gem is noticed.
"""
if getattr(_pool, "_api_version_known", False) is True:
cached = _pool._api_version
return cached if isinstance(cached, tuple) else None
version: tuple[int, ...] | None = None
try:
response = await _pool.send_request("get_api_version")
if isinstance(response, dict) and response.get("status") == "ok":
parsed = json.loads(str(response.get("output", "")))
text = str(parsed.get("api_version", "")) if isinstance(parsed, dict) else ""
parts = text.split(".")
if parts and all(part.isdigit() for part in parts):
version = tuple(int(part) for part in parts)
except (json.JSONDecodeError, TypeError, OSError, ConnectionError, TimeoutError):
version = None
if isinstance(_pool, _EditorConnectionPool):
_pool._api_version = version
_pool._api_version_known = version is not None
return version


# Gem API that takes set_transform "scale" as [x, y, z] and "rotation_quaternion".
_API_NATIVE_SCALE_AND_QUATERNION = (0, 5, 0)


# ---------------------------------------------------------------------------
# Script execution helpers
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1745,9 +1782,28 @@ async def set_transform(
if math.sqrt(sum(v * v for v in rot)) < 1e-12:
raise ValueError("rotation quaternion must not be all zeros.")

# A Transform holds one uniform scale; a non-uniform one would silently
# collapse to its largest value, so refuse it.
if scl is not None and not scl[0] == scl[1] == scl[2]:
uniform = scl is None or scl[0] == scl[1] == scl[2]
# Gem API 0.5.0+ takes the quaternion as is and a non-uniform scale (through
# the engine's Non-uniform Scale component), so send both natively. Older
# gems only matter when a rotation or a non-uniform scale is given; ask
# for the version only then.
if rot is not None or not uniform:
api = await _gem_api_version()
if api is not None and api >= _API_NATIVE_SCALE_AND_QUATERNION:
params_v5: dict[str, object] = {"entity_id": entity_id.strip("[]")}
if pos is not None:
params_v5["position"] = pos
if rot is not None:
params_v5["rotation_quaternion"] = rot
if scl is not None:
params_v5["scale"] = scl[0] if uniform else scl
native_v5 = await _native_mutation("set_transform", params_v5)
if native_v5 is not None:
return native_v5

# Older gems: a Transform holds one uniform scale, and a non-uniform one
# would silently collapse to its largest value, so refuse it.
if not uniform:
return _format_error(
"non_uniform_scale_unsupported",
f"Scale {scl} is not uniform. An O3DE Transform holds only a uniform "
Expand Down Expand Up @@ -1832,7 +1888,14 @@ async def set_transform(

@mcp.tool()
async def get_transform(entity_id: EntityIdArg) -> str:
"""Get the world transform of an entity."""
"""Get the world transform of an entity.

Returns ``position``, ``rotation`` (an ``[x, y, z, w]`` quaternion) and
``scale``, the effective ``[x, y, z]`` scale: the Transform's uniform
scale times the entity's Non-uniform Scale component, if it has one.
``uniform_scale`` and ``non_uniform_scale`` (``null`` without the
component) give the two parts.
"""
entity_id = _validate_entity_id(entity_id)
params = json.dumps({"entity_id": entity_id})
script = textwrap.dedent(f"""\
Expand All @@ -1852,21 +1915,21 @@ async def get_transform(entity_id: EntityIdArg) -> str:
else:
pos = tm.translation
rot = tm.rotation
try:
m3 = math.Matrix3x3_CreateFromTransform(tm)
b0 = m3.BasisX
b1 = m3.BasisY
b2 = m3.BasisZ
import math as _math
scl = [_math.sqrt(b0.x**2 + b0.y**2 + b0.z**2),
_math.sqrt(b1.x**2 + b1.y**2 + b1.z**2),
_math.sqrt(b2.x**2 + b2.y**2 + b2.z**2)]
except Exception:
scl = [1.0, 1.0, 1.0]
_uniform = float(tm.GetUniformScale())
# GetScale answers (0, 0, 0), not None, when the entity has no
# Non-uniform Scale component, so a zero vector means "none".
_nus = entity.NonUniformScaleRequestBus(bus.Event, 'GetScale', eid)
_component = None
if _nus is not None:
_vec = [float(_nus.x), float(_nus.y), float(_nus.z)]
if any(abs(v) > 1e-9 for v in _vec):
_component = _vec
result = {{
'position': [pos.x, pos.y, pos.z],
'rotation': [rot.x, rot.y, rot.z, rot.w],
'scale': scl,
'scale': [_uniform * c for c in (_component or [1.0, 1.0, 1.0])],
'uniform_scale': _uniform,
'non_uniform_scale': _component,
}}
print(json.dumps(result))
""")
Expand Down
Loading
Loading