diff --git a/AGENTS.md b/AGENTS.md index c6840e6..459a825 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,12 +28,15 @@ Made a mistake? → undo / redo ## Tool Surface 96 tools in seven groups. Full parameters in [docs/tool-reference.md](docs/tool-reference.md). +`get_capabilities` groups the same tools by what they need instead: `editor_tools` (70) +holds the Editor, Track View and Animation groups plus the four asset readiness tools, and +`asset_tools` (5) holds the asset tools that work without an editor. | Group | Count | Editor needed | What it covers | |-------|-------|---------------|----------------| | Capabilities | 1 | No | `get_capabilities` — call this first | | Editor | 41 | Yes | Entities, components, transforms, prefabs, levels, viewport/camera, console/CVARs, game mode, undo/redo, persistent sessions | -| Introspection | 3 | Partly | EBus schema (static stubs and live), RenderDoc capture | +| Introspection | 3 | Partly | EBus schema (static stubs and live), RenderDoc capture (manual steps on O3DE 26.10.0) | | Project | 17 | No | Engines, projects, gems, templates, builds (blocking and background), export | | Assets | 9 | Partly | Asset Processor status, refresh/wait, log tailing (no editor); per-asset readiness and build failure logs (editor, gem 0.6.0+) | | Track View | 8 | Yes | Cinematic sequences: create/list/describe/delete, time range, add nodes, play/stop | diff --git a/CHANGELOG.md b/CHANGELOG.md index 7041536..e61cf3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **The last failures reported as successes, and three that never worked.** A + pre-release audit found more paths outside the error envelope, now fixed: + - `set_component_property` printed "Set ..." without checking the result, and + `get_component_property` reported a failed read as `{"value": "None"}`. Both now + check the outcome (`set_property_failed`, `get_property_failed`) and report an + unknown component or one not on the entity. + - `add_component` reported "Added" when the add raised or returned no outcome. + - `get_cvar` answered a CVAR that does not exist with `"value": "(missing)"` (the + engine's own text) and a failed read with `"value": "Error: ..."`; those are now + `cvar_not_found` and `get_cvar_failed`. `get_viewport_camera` returned a bare + `error` key; it now returns `viewport_camera_unavailable`. + - `get_bus_schema_live` answered a bus missing from a real module as a live result + with an `error` key; it now falls back to the stubs and reports `bus_not_found`. + - Three fallbacks addressed Broadcast-only events with `bus.Event` and could never + work; they are gone. + - `instantiate_prefab` rebuilt its `parent_id` with `entity.EntityId(n)`, which is + invalid on 26.10.0, so the parent was silently ignored. It now looks the parent up + (an unknown one is `entity_not_found`). + - With `O3DE_EDITOR_TLS_VERIFY=1` and no `O3DE_EDITOR_TLS_CA`, no CA was trusted, so + every handshake failed. The system CA store is now loaded, as documented. - **`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 @@ -66,8 +86,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Compatibility -- **Entity IDs are tolerated as decimal strings.** AiCompanion gem main (API 0.4.0, - shipping as 0.6.0) emits every native 64-bit entity id as a decimal string instead +- **Entity IDs are tolerated as decimal strings.** AiCompanion gem 0.6.0 (API 0.4.0 + and up) emits every native 64-bit entity id as a decimal string instead of a JSON number, so a client with a 53-bit-float JSON parser cannot corrupt a u64 id. o3de-mcp already carries ids through unchanged and accepts a number or a string on input; tests now pin that a full-precision u64 string round-trips through @@ -123,7 +143,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Animation tools** (`tools/animation.py`, 2 tools): `list_anim_graphs` and `get_anim_graph`, EMotion FX anim graph reads over the AiCompanion gem's native request types. They need gem main or 0.6.0+ and the EMotionFX gem; an older gem - answers with code `unknown_request_type`. No editor-Python fallback (EMotion FX + answers `Unknown request type` (code `unknown_request_type` from gem 0.5.0). No editor-Python fallback (EMotion FX anim graphs are not usefully reflected to Python); they work in secure mode. `get_anim_graph` takes exactly one of `anim_graph_id` (32-bit number or digit string) or `file_name`. `_native_request` moved to module level in `editor.py` diff --git a/CLAUDE.md b/CLAUDE.md index 16d8959..8a9e1be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,8 +6,8 @@ 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. 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. +- **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; the reply carries code `unknown_request_type` from gem 0.5.0) 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 (reports manual steps on O3DE 26.10.0, where the bus is not exposed to editor Python). - **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. - **Track View tools** (`src/o3de_mcp/tools/trackview.py`, 8 tools): cinematic sequence authoring: create/list/describe/delete sequences, set time range, add nodes (Director, Event, entity, component), and play/stop. Editor-Python over `azlmbr.legacy.trackview`; no gem request type. Track and keyframe authoring are intentionally not exposed (the reflected API's per-node parameter-type strings and record-only key workflow are not reliable to drive). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a0cc6de..7fd326d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,7 +58,12 @@ Follow the `register_*_tools(mcp: MCPServer)` pattern: Requirements: - **Validate all user inputs at tool boundaries.** See existing validators in `editor.py` and `project.py` for examples. -- **Never interpolate raw user strings into editor Python scripts.** Use `json.dumps`/`json.loads` round-trip to safely pass values into `pyRunScript` commands. +- **Never interpolate raw user strings into editor Python scripts.** Use a `json.dumps`/`json.loads` round-trip to pass values into the script. Scripts are sent to the AiCompanion gem's AgentServer as framed `execute_python` requests; `pyRunScript` is only the legacy RemoteConsole fallback. +- **Report every failure with the one envelope** `{"status": "error", "code": "", "message": ""}`: `format_error` from `utils/errors.py` in server code, and `_o3de_fail(code, message)` inside generated editor scripts. Never print a plain-text failure. +- **Get entities in editor scripts with `_resolve_entity_id(id)`**, which finds the entity's own id object through the search bus. Never build an id with `entity.EntityId(n)`: on O3DE 26.10.0 it gives an invalid id for every `n`. +- **Annotate entity-id parameters with `EntityIdArg` or `OptionalEntityIdArg`** from `tools/editor.py`, not `int | str`, so a bracketed id like `"[1234]"` is not JSON-decoded into a list. +- **Keep the surface test passing.** `tests/test_editor_scripts.py` runs every generated editor script against the reflected `azlmbr` surface in `tests/data/azlmbr_surface.json`; check that file before calling a bus event or function the tools do not already use. +- **Add a live test for any new editor-side path** in `tests/test_live_editor.py`, and run it with `scripts/live-sandbox.sh` (see `docs/releasing.md`). A mocked test proves only that the tool dispatches. ## Security diff --git a/README.md b/README.md index 536aeda..4f645f3 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ See the [architecture documentation](https://github.com/nickschuetz/o3de-mcp/blo **Engine Introspection** (3 tools): - EBus schema discovery, both from generated `azlmbr` stubs and live from the editor -- RenderDoc frame capture +- RenderDoc frame capture (on O3DE 26.10.0 the editor does not expose it to Python, so the tool reports the manual steps) **Project & Build Management** (17 tools, CLI-based, no editor required): - Discover local O3DE engine installations (multi-engine support), register engines, select the active one diff --git a/docs/architecture.md b/docs/architecture.md index f0f3994..7ddb33f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. 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. +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 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 and up; 0.6.0 reports API 0.5.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 diff --git a/docs/recipes.md b/docs/recipes.md index d33601f..2fb0748 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -165,11 +165,10 @@ missing, set `O3DE_ENGINE_PATH` and try again. ``` 1. create_entity(name="WinZone") 2. add_component(entity_id=, component_type="PhysX Primitive Collider") +3. set_component_property(entity_id=, component_type="PhysX Primitive Collider", + property_path="Collider Configuration|Trigger", value="true") ``` -> Configure the collider as a trigger via `run_editor_python` to set -> `IsTrigger = True`. - --- ## Scripting @@ -183,48 +182,30 @@ missing, set `O3DE_ENGINE_PATH` and try again. ### Recipe: Set entity transform (position/rotation/scale) -```python -# Use via run_editor_python -import azlmbr.components as components -import azlmbr.bus as bus -import azlmbr.math as math - -eid = azlmbr.entity.EntityId('') -pos = math.Vector3(10.0, 5.0, 0.0) -components.TransformBus(bus.Event, 'SetWorldTranslation', eid, pos) ``` +1. set_transform(entity_id=, position=[10.0, 5.0, 0.0]) +2. set_transform(entity_id=, rotation=[0.0, 0.0, 0.7071068, 0.7071068], scale=[2.0, 2.0, 2.0]) +3. get_transform(entity_id=) → read it back +``` + +> Only the parts you pass change. `rotation` is an `[x, y, z, w]` quaternion +> (this one turns 90 degrees about Z). A non-uniform `scale` such as +> `[4.0, 4.0, 0.1]` works on the AiCompanion gem 0.6.0 (gem API 0.5.0) and +> later, through the engine's Non-uniform Scale component; older gems refuse it +> with `non_uniform_scale_unsupported`. ### Recipe: Set mesh asset on entity -```python -# Use via run_editor_python -import azlmbr.editor as editor -import azlmbr.bus as bus -import azlmbr.entity as entity -import azlmbr.asset as asset -import azlmbr.math as math - -eid = azlmbr.entity.EntityId('') -# GetAssetIdByPath takes (path, asset type uuid, auto-register); a null uuid -# matches any type. -mesh_asset = asset.AssetCatalogRequestBus( - bus.Broadcast, 'GetAssetIdByPath', - 'objects/primitives/cube.fbx.azmodel', math.Uuid(), False -) -mesh_t = editor.EditorComponentAPIBus( - bus.Broadcast, 'FindComponentTypeIdsByEntityType', - ['Mesh'], entity.EntityType().Game -)[0] -outcome = editor.EditorComponentAPIBus( - bus.Broadcast, 'GetComponentOfType', eid, mesh_t -) -if outcome.IsSuccess(): - pair = outcome.GetValue() - editor.EditorComponentAPIBus( - bus.Broadcast, 'SetComponentProperty', pair, - 'Controller|Configuration|Model Asset', mesh_asset - ) ``` +assign_asset(entity_id=, component_type="Mesh", + property_path="Controller|Configuration|Model Asset", + asset_path="objects/shaderball/ground_plane_4x4m.fbx.azmodel") +``` + +> The entity needs a Mesh component first (`add_component`). `asset_path` is +> the product path in the asset catalog. `assign_asset` reads the property back +> and reports only an assignment that holds; an unknown asset, a component the +> entity lacks, or a value that does not stick comes back as an error. ### Recipe: Batch-create multiple entities @@ -232,10 +213,11 @@ if outcome.IsSuccess(): # Use via run_editor_python — efficient single call import azlmbr.editor as editor import azlmbr.bus as bus +import azlmbr.entity as entity import json entities = ['Tree_01', 'Tree_02', 'Tree_03', 'Rock_01', 'Rock_02'] -parent = azlmbr.entity.EntityId() +parent = entity.EntityId() # an empty id: create at the level root results = [] for name in entities: eid = editor.ToolsApplicationRequestBus(bus.Broadcast, 'CreateNewEntity', parent) diff --git a/docs/tool-reference.md b/docs/tool-reference.md index ac6f8df..6d201e2 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -24,9 +24,10 @@ RemoteConsole with no gem behind it). ## Introspection Tools -Discover a gem's scripting API. These read the editor's generated stubs from -disk, so they work without a running editor (the project must have been opened -in the editor once to produce the stubs). +Discover a gem's scripting API. `get_bus_schema` reads the editor's generated +stubs from disk, so it works without a running editor (the project must have been +opened in the editor once to produce them). `get_bus_schema_live` and +`capture_renderdoc_frame` need the editor. ### get_bus_schema @@ -61,26 +62,29 @@ Query the running editor's BehaviorContext for a bus schema. Falls back to | `bus` | str | yes | Bus name (e.g. `PhysicsRequestBus`) | | `project_path` | str | no | Project path for fallback stub resolution | -Returns JSON with a `source` field: `"live"` or `"stub_fallback"`. - Tries the AiCompanion gem's native `get_bus_schema` request first (gem 0.4.0 or later; includes argument names and tooltips, works in secure mode), then the editor-Python query, then the stub files. The `source` field says which -answered: `native`, `live` or `stub_fallback`. +answered: `native`, `live` or `stub_fallback`. When none has the bus, the reply +is the error `bus_not_found` with `source: "stub_fallback_failed"`. ### capture_renderdoc_frame -Trigger a RenderDoc frame capture in the O3DE editor. Sends the -`r_captureFrame` console command. After capture, use the `renderdoc-mcp` MCP -server tools to analyze the frame. No parameters. +Trigger a RenderDoc frame capture in the O3DE editor through +`GraphicsProfilerBus.TriggerCapture` when the editor exposes it to Python. On +O3DE 26.10.0 it does not, so the tool returns `status: "manual_required"` with the +manual alternatives (F12 in RenderDoc, or the editor's GPU capture menu). After a +capture, use the `renderdoc-mcp` MCP server tools to analyze the frame. No +parameters. --- ## Editor Tools Require a running O3DE Editor with AiCompanion + EditorPythonBindings gems. -If the editor is unreachable, these tools will fast-fail with an -`editor_unavailable` error within seconds rather than timing out. +If the editor is unreachable, the first call fails within the connect timeout +(`connection_refused` or `timeout`), and calls in the next 5 seconds fail at once +with `editor_unavailable`, rather than each waiting out the timeout. ### run_editor_python @@ -106,7 +110,9 @@ Entity hierarchy as a nested JSON tree, served natively by the gem. No parameter One entity's name, transform, parent and component list as JSON, served natively by the gem's C++ (`get_entity` request type, gem 0.4.0 or later). -Works in secure mode. An unknown id returns `{"error": ...}`. +Works in secure mode. On gem 0.6.0 the reply also carries `non_uniform_scale` +and `effective_scale`. An unknown id is the error `not_found` (gem 0.5.0 and +earlier answered with the gem's own `{"entity_id": ..., "error": ...}` object). | Param | Type | Required | Description | |-------|------|----------|-------------| @@ -133,7 +139,8 @@ Create a new entity in the current level. Tries the AiCompanion gem's native `create_entity` request first (gem 0.5.0 or later, works in secure mode) and returns its JSON verbatim: -`{"entity_id": 123, "name": "...", "position": [0.0, 0.0, 0.0]}`. The name is +`{"entity_id": "123", "name": "...", "position": [0.0, 0.0, 0.0]}` (gem 0.6.0 +sends the id as a decimal string; gem 0.5.0 sent a number). The name is checked with the gem's own rule before either path (a letter first, then letters, digits, `_` or `-`, at most 128 characters); the gem validates the parent, and a refusal comes back as an error and is not retried through @@ -180,7 +187,7 @@ Add a component to an entity. | Param | Type | Required | Description | |-------|------|----------|-------------| | `entity_id` | int or str | yes | Target entity ID | -| `component_type` | str | yes | Component name (e.g. `Mesh`, `PhysX Rigid Body`) | +| `component_type` | str | yes | Component name (e.g. `Mesh`, `PhysX Dynamic Rigid Body`) | ### get_component_property @@ -212,7 +219,7 @@ Assign an asset to a component property by resolving the asset path to an O3DE a | `entity_id` | int or str | yes | Entity ID | | `component_type` | str | yes | Component type name | | `property_path` | str | yes | Property path with `\|` separator | -| `asset_path` | str | yes | Project-relative asset path (e.g. `Objects/Props/box.fbx`) | +| `asset_path` | str | yes | Product path in the asset catalog (e.g. `objects/props/box.fbx.azmodel`) | ### remove_component @@ -234,29 +241,25 @@ Set the world transform of an entity. Only provided components are changed. | `rotation` | list[float] | no | [x, y, z, w] quaternion rotation (4 elements) | | `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 -gem takes (the inverse of its own `CreateFromEulerDegreesXYZ`) and a uniform -scale to its single number. On success it returns the updated entity's JSON -verbatim, the same shape as `get_entity`. The gem refuses an unknown entity, an -out-of-bounds position or a scale outside (0, 1000], and that is returned as an -error. Older gems, the legacy transport and a rotation at a gimbal pole (pitch -within 0.02 degrees of plus or minus 90, where the Euler form cannot carry roll -and yaw separately) use the editor Python path, which applies the quaternion -directly, keeps the current scale when none is given, and prints -`Transform set for entity []`. An all-zero quaternion is rejected before -either path. - -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. +Goes to the AiCompanion gem's native `set_transform` request (works in secure mode) +and returns the updated entity's JSON, the same shape as `get_entity`. The gem refuses +an unknown entity or out-of-bounds values, and that is returned as an error. + +On gem API 0.5.0 and later (gem 0.6.0) the quaternion and the scale are sent as is. +An O3DE Transform holds only a uniform scale, so a non-uniform `scale` is applied +through the engine's Non-uniform Scale component (as the Transform's "Add non-uniform +scale" button does), with the Transform's scale set to 1, in one undo step. A later +uniform scale sets that component back to `[1, 1, 1]`. The reply carries `scale` (the +uniform scale), `non_uniform_scale` and `effective_scale`. + +On older gems the quaternion is converted to the XYZ Euler degrees the gem takes, and a +rotation at a gimbal pole (pitch within 0.02 degrees of plus or minus 90, where the +Euler form cannot carry roll and yaw separately) goes through editor Python instead. +That path applies the quaternion directly, keeps the current scale when none is given, +and prints `Transform set for entity []`. A non-uniform `scale` is refused with +`non_uniform_scale_unsupported` (it used to be applied silently as its largest value), +because editor Python cannot add the Non-uniform Scale component. The legacy transport +also uses the editor-Python path. An all-zero quaternion is rejected before any path. ### get_transform @@ -367,7 +370,9 @@ Redo the last undone action. No parameters. ### get_viewport_camera Get the active editor viewport camera transform. No parameters. -Returns JSON: `{"position": [...], "rotation": [...], "fov": ...}` +Returns JSON: `{"position": [x, y, z], "rotation": [x, y, z]}` (rotation in Euler +degrees). The field of view is not available to editor Python. When the editor +reports no camera, the error is `viewport_camera_unavailable`. ### set_viewport_camera @@ -376,7 +381,7 @@ Set the active editor viewport camera transform. | Param | Type | Required | Description | |-------|------|----------|-------------| | `position` | list[float] | no | [x, y, z] camera position | -| `rotation` | list[float] | no | [x, y, z, w] quaternion rotation | +| `rotation` | list[float] | no | [x, y, z] Euler angles in degrees | ### focus_entity @@ -388,13 +393,16 @@ Focus the viewport camera on an entity. ### capture_viewport -Capture a screenshot of the editor viewport. +Capture a screenshot of the editor viewport with Atom's `CaptureScreenshot`, then +wait on the client for a new, fully written file (`O3DE_CAPTURE_WAIT`, default 15 s). +A level must be open; otherwise no file is written and the error is +`capture_not_written`. | Param | Type | Required | Description | |-------|------|----------|-------------| -| `output_path` | str | yes | File path (must end in .png/.jpg/.bmp/.tga) | -| `width` | int | no | Capture width in pixels | -| `height` | int | no | Capture height in pixels | +| `output_path` | str | yes | File path (must end in .png/.jpg/.jpeg/.bmp/.tga) | +| `width` | int | no | Ignored on O3DE 26.10.0 (applies only to a PySide6 grab the editor cannot run) | +| `height` | int | no | Ignored on O3DE 26.10.0, like `width` | ### instantiate_prefab @@ -417,7 +425,9 @@ Create a prefab file from an existing entity. ### save_prefab -Save a prefab instance (propagate entity changes to the prefab file). +Saving entity edits back into an existing prefab is not exposed to editor Python, +so this always returns the error `prefab_save_unavailable`, with `owning_prefab` +naming the prefab file to edit. Use `create_prefab_from_entity` to write a new one. | Param | Type | Required | Description | |-------|------|----------|-------------| @@ -463,8 +473,8 @@ authoring are not exposed. ### list_sequences -List the cinematic sequences in the current level. Returns a JSON array of -`{name, start, end}`. No parameters. +List the cinematic sequences in the current level. Returns +`{"sequences": [{"name", "start", "end"}, ...], "count": N}`. No parameters. ### create_sequence @@ -526,8 +536,9 @@ Stop the sequence currently playing. No parameters. ## Animation Tools EMotion FX anim graph reads and authoring over the AiCompanion gem's native C++ request -types. They need gem main or 0.6.0+ and the EMotionFX gem; an older gem answers with code -`unknown_request_type`. Native only (no editor-Python fallback); they work in secure +types. They need gem 0.6.0+ and the EMotionFX gem; an older gem answers with the message +`Unknown request type: ...` (code `unknown_request_type` from gem 0.5.0; earlier gems +send no code, so it arrives as `editor_error`). Native only (no editor-Python fallback); they work in secure mode. Graph ids are 32-bit numbers; node, transition and entity ids are decimal strings. Authoring writes refuse a graph owned by an asset or a running actor instance @@ -883,8 +894,11 @@ Returns JSON: `{"status": "running|completed|failed", "returncode": N, "output": ## Asset Tools -Monitor the Asset Processor and read diagnostic log files. These tools work -with the filesystem and process list — they do not require a running editor. +Monitor the Asset Processor, read diagnostic log files, and check single assets. +`get_asset_processor_status`, `wait_for_assets`, `refresh_assets`, `tail_log` and +`get_log_errors` work from the filesystem and process list without an editor. The +readiness tools (`get_asset_status`, `get_asset_jobs`, `get_asset_processor_connection`, +`wait_for_asset`) ask the Asset Processor through the editor and need gem 0.6.0+. ### get_asset_processor_status diff --git a/examples/02_build_scene.md b/examples/02_build_scene.md index 62cec44..63bf09f 100644 --- a/examples/02_build_scene.md +++ b/examples/02_build_scene.md @@ -60,9 +60,11 @@ Capture the returned entity ID, then: {"tool": "add_component", "arguments": {"entity_id": "", "component_type": "PhysX Primitive Collider"}} ``` -Give it a flat mesh and size it. An O3DE Transform holds only a uniform scale -(`set_transform` refuses a non-uniform one), so start from a flat mesh: the -engine's 4 x 4 m ground plane, scaled uniformly by 12.5, is a 50 x 50 m ground. +Give it a flat mesh and size it. The engine's 4 x 4 m ground plane, scaled +uniformly by 12.5, is a 50 x 50 m ground. `set_transform` also takes a +non-uniform `[x, y, z]` scale on the AiCompanion gem 0.6.0 (gem API 0.5.0) and +later, which applies it through the engine's Non-uniform Scale component; older +gems refuse a non-uniform scale with `non_uniform_scale_unsupported`. ```json {"tool": "assign_asset", "arguments": {"entity_id": "", "component_type": "Mesh", "property_path": "Controller|Configuration|Model Asset", "asset_path": "objects/shaderball/ground_plane_4x4m.fbx.azmodel"}} @@ -83,12 +85,7 @@ component itself rather than through the scale. Position the camera: ```json -{ - "tool": "run_editor_python", - "arguments": { - "script": "import azlmbr.components as comp\nimport azlmbr.bus as bus\nimport azlmbr.entity as entity\nimport azlmbr.math as math\n\neid = entity.EntityId('')\ncomp.TransformBus(bus.Event, 'SetWorldTranslation', eid, math.Vector3(0.0, -10.0, 5.0))" - } -} +{"tool": "set_transform", "arguments": {"entity_id": "", "position": [0.0, -10.0, 5.0]}} ``` ### 7. Add some static objects diff --git a/examples/03_physics_playground.md b/examples/03_physics_playground.md index 69f255d..3f27b60 100644 --- a/examples/03_physics_playground.md +++ b/examples/03_physics_playground.md @@ -25,12 +25,7 @@ Add dynamic physics objects to a scene — crates, balls, and trigger zones. Position it above the ground so it falls when play mode starts: ```json -{ - "tool": "run_editor_python", - "arguments": { - "script": "import azlmbr.components as comp\nimport azlmbr.bus as bus\nimport azlmbr.entity as entity\nimport azlmbr.math as math\n\neid = entity.EntityId('')\ncomp.TransformBus(bus.Event, 'SetWorldTranslation', eid, math.Vector3(0.0, 0.0, 10.0))" - } -} +{"tool": "set_transform", "arguments": {"entity_id": "", "position": [0.0, 0.0, 10.0]}} ``` ### 2. Batch-create a stack of crates @@ -77,12 +72,7 @@ A trigger zone detects when entities enter/exit without blocking them: Configure it as a trigger: ```json -{ - "tool": "run_editor_python", - "arguments": { - "script": "import azlmbr.editor as editor\nimport azlmbr.bus as bus\nimport azlmbr.entity as entity\n\neid = entity.EntityId('')\ncollider_types = editor.EditorComponentAPIBus(bus.Broadcast, 'FindComponentTypeIdsByEntityType', ['PhysX Primitive Collider'], entity.EntityType().Game)\noutcome = editor.EditorComponentAPIBus(bus.Broadcast, 'GetComponentOfType', eid, collider_types[0])\nif outcome.IsSuccess():\n pair = outcome.GetValue()\n editor.EditorComponentAPIBus(bus.Broadcast, 'SetComponentProperty', pair, 'PhysX Primitive Collider|IsTrigger', True)\nprint('GoalZone configured as trigger')" - } -} +{"tool": "set_component_property", "arguments": {"entity_id": "", "component_type": "PhysX Primitive Collider", "property_path": "Collider Configuration|Trigger", "value": "true"}} ``` ### 4. Create a bouncing ball @@ -94,15 +84,12 @@ Configure it as a trigger: {"tool": "add_component", "arguments": {"entity_id": "", "component_type": "PhysX Dynamic Rigid Body"}} ``` -Set the collider shape to sphere and configure restitution for bouncing: +Position it, and set the collider shape to a sphere (the `Shape` value `0` is +the engine's `ShapeType` Sphere): ```json -{ - "tool": "run_editor_python", - "arguments": { - "script": "import azlmbr.editor as editor\nimport azlmbr.bus as bus\nimport azlmbr.components as comp\nimport azlmbr.math as math\nimport azlmbr.entity as entity\n\neid = entity.EntityId('')\ncomp.TransformBus(bus.Event, 'SetWorldTranslation', eid, math.Vector3(5.0, 0.0, 15.0))\ncollider_types = editor.EditorComponentAPIBus(bus.Broadcast, 'FindComponentTypeIdsByEntityType', ['PhysX Primitive Collider'], entity.EntityType().Game)\noutcome = editor.EditorComponentAPIBus(bus.Broadcast, 'GetComponentOfType', eid, collider_types[0])\nif outcome.IsSuccess():\n pair = outcome.GetValue()\n editor.EditorComponentAPIBus(bus.Broadcast, 'SetComponentProperty', pair, 'PhysX Primitive Collider|Shape|Shape Configuration|Sphere', True)\nprint('Ball configured')" - } -} +{"tool": "set_transform", "arguments": {"entity_id": "", "position": [5.0, 0.0, 15.0]}} +{"tool": "set_component_property", "arguments": {"entity_id": "", "component_type": "PhysX Primitive Collider", "property_path": "Shape Configuration|Shape", "value": "0"}} ``` ### 5. Verify physics setup diff --git a/examples/04_scripted_game.md b/examples/04_scripted_game.md index d30d368..b0bea57 100644 --- a/examples/04_scripted_game.md +++ b/examples/04_scripted_game.md @@ -24,15 +24,10 @@ ball must reach a goal zone while crates fall from above. {"tool": "add_component", "arguments": {"entity_id": "", "component_type": "Lua Script"}} ``` -Position and configure the player: +Position and scale the player: ```json -{ - "tool": "run_editor_python", - "arguments": { - "script": "import azlmbr.components as comp\nimport azlmbr.bus as bus\nimport azlmbr.entity as entity\nimport azlmbr.math as math\n\neid = entity.EntityId('')\ncomp.TransformBus(bus.Event, 'SetWorldTranslation', eid, math.Vector3(0.0, 0.0, 1.0))\ncomp.TransformBus(bus.Event, 'SetLocalUniformScale', eid, 0.5)\nprint('Player positioned')" - } -} +{"tool": "set_transform", "arguments": {"entity_id": "", "position": [0.0, 0.0, 1.0], "scale": [0.5, 0.5, 0.5]}} ``` ### 2. Create the goal zone @@ -43,16 +38,11 @@ Position and configure the player: {"tool": "add_component", "arguments": {"entity_id": "", "component_type": "Lua Script"}} ``` -Position it, scale it to a 3 m cube (a Transform holds only a uniform scale), -and make it a trigger: +Position it, scale it to a 3 m cube, and make it a trigger: ```json -{ - "tool": "run_editor_python", - "arguments": { - "script": "import azlmbr.editor as editor\nimport azlmbr.components as comp\nimport azlmbr.bus as bus\nimport azlmbr.entity as entity\nimport azlmbr.math as math\n\neid = entity.EntityId('')\ncomp.TransformBus(bus.Event, 'SetWorldTranslation', eid, math.Vector3(20.0, 0.0, 1.5))\ncomp.TransformBus(bus.Event, 'SetLocalUniformScale', eid, 3.0)\ncol_t = editor.EditorComponentAPIBus(bus.Broadcast, 'FindComponentTypeIdsByEntityType', ['PhysX Primitive Collider'], entity.EntityType().Game)[0]\noutcome = editor.EditorComponentAPIBus(bus.Broadcast, 'GetComponentOfType', eid, col_t)\nif outcome.IsSuccess():\n pair = outcome.GetValue()\n editor.EditorComponentAPIBus(bus.Broadcast, 'SetComponentProperty', pair, 'IsTrigger', True)\nprint('Goal zone configured')" - } -} +{"tool": "set_transform", "arguments": {"entity_id": "", "position": [20.0, 0.0, 1.5], "scale": [3.0, 3.0, 3.0]}} +{"tool": "set_component_property", "arguments": {"entity_id": "", "component_type": "PhysX Primitive Collider", "property_path": "Collider Configuration|Trigger", "value": "true"}} ``` ### 3. Create falling crate spawners @@ -63,13 +53,15 @@ Use entity hierarchy for organization: {"tool": "create_entity", "arguments": {"name": "CrateSpawners"}} ``` -Create spawn points as children: +Create spawn points as children. The script finds the parent's own id with a +search by name; building one with `entity.EntityId('')` gives an +invalid id on O3DE 26.10.0, and the spawners would land at the root: ```json { "tool": "run_editor_python", "arguments": { - "script": "import azlmbr.editor as editor\nimport azlmbr.components as comp\nimport azlmbr.bus as bus\nimport azlmbr.entity as entity\nimport azlmbr.math as math\nimport json\n\nparent = entity.EntityId('')\npositions = [\n (5.0, 0.0, 20.0),\n (10.0, 2.0, 22.0),\n (15.0, -2.0, 18.0),\n (8.0, 3.0, 25.0),\n]\nresults = []\nfor i, (x, y, z) in enumerate(positions):\n eid = editor.ToolsApplicationRequestBus(bus.Broadcast, 'CreateNewEntity', parent)\n editor.EditorEntityAPIBus(bus.Event, 'SetName', eid, f'Spawner_{i:02d}')\n comp.TransformBus(bus.Event, 'SetWorldTranslation', eid, math.Vector3(x, y, z))\n results.append({'name': f'Spawner_{i:02d}', 'id': str(eid)})\nprint(json.dumps(results))" + "script": "import azlmbr.editor as editor\nimport azlmbr.components as comp\nimport azlmbr.bus as bus\nimport azlmbr.entity as entity\nimport azlmbr.math as math\nimport json\n\nsearch = entity.SearchFilter()\nsearch.names = ['CrateSpawners']\nparent = entity.SearchBus(bus.Broadcast, 'SearchEntities', search)[0]\npositions = [\n (5.0, 0.0, 20.0),\n (10.0, 2.0, 22.0),\n (15.0, -2.0, 18.0),\n (8.0, 3.0, 25.0),\n]\nresults = []\nfor i, (x, y, z) in enumerate(positions):\n eid = editor.ToolsApplicationRequestBus(bus.Broadcast, 'CreateNewEntity', parent)\n editor.EditorEntityAPIBus(bus.Event, 'SetName', eid, f'Spawner_{i:02d}')\n comp.TransformBus(bus.Event, 'SetWorldTranslation', eid, math.Vector3(x, y, z))\n results.append({'name': f'Spawner_{i:02d}', 'id': str(eid)})\nprint(json.dumps(results))" } } ``` @@ -104,12 +96,7 @@ Batch-create crates at spawner positions: Position behind and above the player: ```json -{ - "tool": "run_editor_python", - "arguments": { - "script": "import azlmbr.components as comp\nimport azlmbr.bus as bus\nimport azlmbr.entity as entity\nimport azlmbr.math as math\n\neid = entity.EntityId('')\ncomp.TransformBus(bus.Event, 'SetWorldTranslation', eid, math.Vector3(0.0, -8.0, 6.0))\nprint('Camera positioned')" - } -} +{"tool": "set_transform", "arguments": {"entity_id": "", "position": [0.0, -8.0, 6.0]}} ``` ### 7. Verify the complete scene @@ -158,7 +145,7 @@ FallingCrate_* (Mesh + PhysX) ## Token Efficiency Notes -This example used **20 tool calls** (2 of them for verification) to create a +This example used **21 tool calls** (2 of them for verification) to create a game with 13 entities. Key savings: 1. **Batch entity creation** via `run_editor_python` loops — one call creates 4 diff --git a/examples/06_cli_only_workflow.md b/examples/06_cli_only_workflow.md index 0402867..fb49c8c 100644 --- a/examples/06_cli_only_workflow.md +++ b/examples/06_cli_only_workflow.md @@ -37,7 +37,7 @@ export. All operations are done via CLI tools. "engine_version": "24.09" }, "tool_categories": { - "editor_tools": {"available": false, "reason": "Editor not connected", "tool_count": 66}, + "editor_tools": {"available": false, "reason": "Editor not connected", "tool_count": 70}, "project_tools": {"available": true, "reason": null, "tool_count": 17}, "asset_tools": {"available": true, "reason": null, "tool_count": 5}, "introspection_tools": {"available": true, "reason": null, "tool_count": 3}, @@ -171,7 +171,7 @@ If you added a gem that isn't needed: | `get_log_errors` | Yes | Reads the project's log files | | `get_bus_schema` | Yes | Reads the editor's generated azlmbr stubs from disk | | `list_levels` | Yes | Reads the project's Levels/ directory | -| Editor tools (66, except `list_levels`) | **No** | Require running editor | +| Editor tools (70, except `list_levels`) | **No** | Require running editor (includes the per-asset readiness tools `get_asset_status`, `get_asset_jobs`, `get_asset_processor_connection` and `wait_for_asset`) | ## What's Next diff --git a/examples/08_mcp_inspector.md b/examples/08_mcp_inspector.md index 314ea9e..9f4272f 100644 --- a/examples/08_mcp_inspector.md +++ b/examples/08_mcp_inspector.md @@ -26,7 +26,7 @@ Open `http://localhost:6274` in your browser. ### 2. Browse available tools -In the Inspector UI, click **Tools** in the left sidebar. You should see all 92 +In the Inspector UI, click **Tools** in the left sidebar. You should see all 96 registered tools, from the seven tool modules: capabilities, editor, introspection, project, assets, Track View and animation. @@ -44,8 +44,8 @@ needed. "port": 4600, "agent_server": { "protocol_version": 1, - "gem_version": "0.5.0", - "api_version": "0.4.0" + "gem_version": "0.6.0", + "api_version": "0.5.0" }, "ai_companion_gem": true }, @@ -56,7 +56,7 @@ needed. "engine_version": "24.09" }, "tool_categories": { - "editor_tools": {"available": true, "reason": null, "tool_count": 66}, + "editor_tools": {"available": true, "reason": null, "tool_count": 70}, "project_tools": {"available": true, "reason": null, "tool_count": 17}, "asset_tools": {"available": true, "reason": null, "tool_count": 5}, "introspection_tools": {"available": true, "reason": null, "tool_count": 3}, @@ -67,7 +67,8 @@ needed. Each category also carries a `tools` array with its tool names, left out here for brevity. `editor_tools` counts the editor, Track View and animation tools -together (41 + 8 + 17). +and the four per-asset readiness tools from the assets module together +(41 + 8 + 17 + 4). ### 4. List registered projects diff --git a/skills/o3de-headless-and-editor-automation/reference/editor-automation.md b/skills/o3de-headless-and-editor-automation/reference/editor-automation.md index 70ae0c8..7a70af1 100644 --- a/skills/o3de-headless-and-editor-automation/reference/editor-automation.md +++ b/skills/o3de-headless-and-editor-automation/reference/editor-automation.md @@ -21,7 +21,7 @@ differ (use raw strings for Windows paths). Useful `azlmbr` calls for wiring com ## Channel 2 — o3de-mcp + AiCompanion gem -- o3de-mcp (https://github.com/nickschuetz/o3de-mcp) exposes 92 tools: +- o3de-mcp (https://github.com/nickschuetz/o3de-mcp) exposes 96 tools: - Editor (41): entity and component CRUD, transforms, prefabs, levels, viewport/camera, console/CVARs, game mode, `run_editor_python`, persistent sessions (`begin_session` / `exec_in_session` / `end_session`), native scene @@ -39,9 +39,14 @@ differ (use raw strings for Windows paths). Useful `azlmbr` calls for wiring com `disconnect_anim_graph_ports`. - Introspection (3): `get_bus_schema`, `get_bus_schema_live`, `capture_renderdoc_frame`. - - Project/build (17), AP and logs (5), and `get_capabilities`. - - `get_capabilities` counts the editor, Track View and anim graph tools - together as `editor_tools` (66). + - Assets (9): 5 that work without the editor (`get_asset_processor_status`, + `wait_for_assets`, `refresh_assets`, `tail_log`, `get_log_errors`) and 4 + per-asset readiness tools that need it and gem 0.6.0+ (`get_asset_status`, + `get_asset_jobs`, `get_asset_processor_connection`, `wait_for_asset`). + - Project/build (17) and `get_capabilities`. + - `get_capabilities` counts the editor, Track View and anim graph tools and + the 4 readiness tools together as `editor_tools` (70); its `asset_tools` (5) + are the ones that work without the editor. - AiCompanion gem (https://github.com/nickschuetz/o3de-ai-companion-gem, gem name `AiCompanion`) hosts the AgentServer on port 4600 that o3de-mcp talks to, plus the `ai_companion` Python API (builders, templates, snapshot, validation). @@ -50,16 +55,21 @@ differ (use raw strings for Windows paths). Useful `azlmbr` calls for wiring com `editor.agent_server` versions means the gem answered `get_api_version`; a bare "connected" with `ai_companion_gem: false` is a legacy RemoteConsole with no gem, so `import ai_companion` will fail there. -- `get_scene_snapshot`, `get_entity_tree`, `get_entity`, `validate_scene` and all - 17 anim graph tools are native only: they return the gem's C++ output without - editor Python, so they still work when the gem's secure mode disables - `execute_python`. The snapshot reads are cheaper than `list_entities` plus - per-entity calls. +- `get_scene_snapshot`, `get_entity_tree`, `get_entity`, `validate_scene`, all + 17 anim graph tools and the 4 per-asset readiness tools are native only: they + return the gem's C++ output without editor Python, so they still work when the + gem's secure mode disables `execute_python`. The snapshot reads are cheaper + than `list_entities` plus per-entity calls. - `create_entity`, `set_transform`, `delete_entity` and `get_bus_schema_live` try their native request first and fall back to editor Python on an older gem or the legacy RemoteConsole transport. A native refusal from the three mutation tools is returned as an error, never retried through Python; `get_bus_schema_live` also falls back on an unknown bus, and then to the stubs. +- `set_transform` on gem API 0.5.0+ (gem 0.6.0) sends the rotation quaternion + and a non-uniform `[x, y, z]` scale natively; the gem applies that scale + through the engine's Non-uniform Scale component. On older gems it sends Euler + degrees, uses editor Python for a rotation at a gimbal pole, and refuses a + non-uniform scale with `non_uniform_scale_unsupported`. - For multi-step authoring, open one session and `exec_in_session` each step; the editor's main thread drains between requests (this avoids the SetName race the single-script form hits), and imports persist across steps. @@ -75,9 +85,9 @@ differ (use raw strings for Windows paths). Useful `azlmbr` calls for wiring com box (`Xvfb :99 -screen 0 1600x900x24 -nocursor`, then `DISPLAY=:99 Editor ...`). `--rhi=vulkan --rhi-device-validation=disable` works on both OSes. - The editor throttles frames when its window is not focused. Set CVar - `ed_keepEditorActive 1` (console, or `run_console` over the remote console) so - frames advance while the window is unfocused/headless; otherwise automation - appears to hang. + `ed_keepEditorActive 1` (console, or o3de-mcp's `set_cvar` or + `run_console_command`) so frames advance while the window is + unfocused/headless; otherwise automation appears to hang. - A phantom engine registration in `~/.o3de/o3de_manifest.json` (e.g. a pip/venv `o3de` python package path listed under `engines` with the same name+version as the real SDK) makes AP pick the wrong root and crash-loop with "no platforms diff --git a/skills/o3de-headless-and-editor-automation/reference/tooling-and-copr.md b/skills/o3de-headless-and-editor-automation/reference/tooling-and-copr.md index d201b1c..79e523d 100644 --- a/skills/o3de-headless-and-editor-automation/reference/tooling-and-copr.md +++ b/skills/o3de-headless-and-editor-automation/reference/tooling-and-copr.md @@ -3,9 +3,9 @@ ## Automation helpers - **o3de-mcp** — MCP server exposing O3DE editor automation to an agent loop - (92 tools: entities, components, prefabs, levels, viewport, console, sessions, + (96 tools: entities, components, prefabs, levels, viewport, console, sessions, native scene snapshot, Track View sequences, EMotion FX anim graphs, bus schema - introspection, project/build, AP and logs). + introspection, project/build, AP and logs, per-asset readiness). https://github.com/nickschuetz/o3de-mcp Requires the `mcp` **2.x** Python SDK (`mcp[cli]>=2,<3`). With a 1.x `mcp` in the same interpreter the server dies at import (`cannot import name diff --git a/src/o3de_mcp/tools/animation.py b/src/o3de_mcp/tools/animation.py index d7a6060..a2b4282 100644 --- a/src/o3de_mcp/tools/animation.py +++ b/src/o3de_mcp/tools/animation.py @@ -8,8 +8,10 @@ EMotion FX anim graphs are not usefully reflected to editor Python, so these tools call the AiCompanion gem's native C++ request types directly. They need a gem that serves them (gem main, or 0.6.0 and later) and the EMotionFX gem -enabled in the project. An older gem answers with the failure envelope and code -``unknown_request_type``; a project without EMotion FX answers with code +enabled in the project. A gem that predates these request types answers with +the failure envelope and the message ``Unknown request type: ...``, with code +``unknown_request_type`` on gem 0.5.0 (gem 0.4.0 and earlier send no code, so it +arrives as ``editor_error``); a project without EMotion FX answers with code ``unavailable``. There is no editor-Python fallback, and every tool works in the gem's secure mode. diff --git a/src/o3de_mcp/tools/editor.py b/src/o3de_mcp/tools/editor.py index 945813a..4563418 100644 --- a/src/o3de_mcp/tools/editor.py +++ b/src/o3de_mcp/tools/editor.py @@ -590,6 +590,10 @@ def _get_tls_context() -> ssl_module.SSLContext | None: ca_path = os.environ.get("O3DE_EDITOR_TLS_CA") if ca_path: ctx.load_verify_locations(ca_path) + else: + # Without a CA file, verify against the system's trusted CAs; a bare + # PROTOCOL_TLS_CLIENT context trusts nothing, so every handshake failed. + ctx.load_default_certs() return ctx @@ -1147,8 +1151,11 @@ async def get_entity(entity_id: EntityIdArg) -> str: Served natively by the AiCompanion gem's C++ (``get_entity`` request type, gem 0.4.0 or later) with no editor Python, so it is the cheapest - way to look at a single entity and works in the gem's secure mode. An - unknown id returns a JSON object with an ``error`` field. + way to look at a single entity and works in the gem's secure mode. On + gem 0.6.0 the reply also carries ``non_uniform_scale`` and + ``effective_scale``, and ids are decimal strings. An unknown id is the + error ``not_found`` (gem 0.5.0 and earlier answered with the gem's own + ``{"entity_id": ..., "error": ...}`` object instead). """ entity_id = _validate_entity_id(entity_id) return await _native_request("get_entity", {"entity_id": entity_id.strip("[]")}) @@ -1301,10 +1308,9 @@ async def duplicate_entity(entity_id: EntityIdArg) -> str: _params = json.loads({params!r}) # DuplicateEntitiesInInstance needs the editor's own EntityId object: an id - # rebuilt with EntityId(int(...)) passes IsValid() but GetCulledEntityHierarchy - # returns nothing for it, so duplication fails with "empty list of input - # entities". Take the id straight from the entity search instead of the shared - # resolver, which would rebuild it. + # rebuilt with EntityId(int(...)) is not usable (on 26.10.0 it is invalid for + # every n), so duplication would fail with "empty list of input entities". + # Take the id object straight from the entity search. _wanted = str(_params['entity_id']).strip('[]') eid = None _all = entity.SearchBus(bus.Broadcast, 'SearchEntities', entity.SearchFilter()) @@ -1424,27 +1430,14 @@ async def add_component(entity_id: EntityIdArg, component_type: str) -> str: _o3de_fail('component_type_not_found', f'Component type "{{comp_type}}" not found') else: tid = type_ids[0] - # O3DE 2510+: AddComponentOfType (singular) via Broadcast - try: - outcome = editor.EditorComponentAPIBus( - bus.Broadcast, 'AddComponentOfType', eid, tid - ) - if hasattr(outcome, 'IsSuccess'): - if outcome.IsSuccess(): - print(f'Added {{comp_type}} to {{eid}}') - else: - err = outcome.GetError() if hasattr(outcome, 'GetError') else 'unknown' - _o3de_fail( - 'add_component_failed', - f'Failed to add {{comp_type}}: {{err}}') - else: - print(f'Added {{comp_type}} to {{eid}}') - except Exception: - # Fallback: legacy AddComponentsOfType via Event bus - editor.EditorComponentAPIBus( - bus.Event, 'AddComponentsOfType', eid, type_ids - ) + outcome = editor.EditorComponentAPIBus( + bus.Broadcast, 'AddComponentOfType', eid, tid + ) + if hasattr(outcome, 'IsSuccess') and outcome.IsSuccess(): print(f'Added {{comp_type}} to {{eid}}') + else: + err = outcome.GetError() if hasattr(outcome, 'GetError') else outcome + _o3de_fail('add_component_failed', f'Failed to add {{comp_type}}: {{err}}') """) return await _async_run_editor_script(script) @@ -1478,40 +1471,42 @@ async def get_component_property( _params = json.loads({params!r}) eid = _resolve_entity_id(_params['entity_id']) - value = None - # O3DE 2510+: resolve EntityComponentIdPair, then get property type_ids = editor.EditorComponentAPIBus( bus.Broadcast, 'FindComponentTypeIdsByEntityType', [_params['component_type']], entity.EntityType().Game ) - try: - if type_ids: - outcome = editor.EditorComponentAPIBus( - bus.Broadcast, 'GetComponentOfType', eid, type_ids[0] - ) - if hasattr(outcome, 'IsSuccess') and outcome.IsSuccess(): - pair = outcome.GetValue() - prop = editor.EditorComponentAPIBus( - bus.Broadcast, 'GetComponentProperty', pair, - _params['property_path'] - ) - if hasattr(prop, 'IsSuccess') and prop.IsSuccess(): - value = prop.GetValue() - elif hasattr(prop, 'IsSuccess'): - value = None - else: - value = prop - else: - raise RuntimeError('GetComponentOfType failed') + _null = '00000000-0000-0000-0000-000000000000' + pair = None + if not type_ids or _null in str(type_ids[0]): + _o3de_fail( + 'component_type_not_found', + f'Component type "{{_params["component_type"]}}" not found', + ) + else: + found = editor.EditorComponentAPIBus( + bus.Broadcast, 'GetComponentOfType', eid, type_ids[0] + ) + if hasattr(found, 'IsSuccess') and found.IsSuccess(): + pair = found.GetValue() else: - raise RuntimeError('type not found') - except Exception: - # Fallback: legacy API with bare EntityId - value = editor.EditorComponentAPIBus( - bus.Event, 'GetComponentProperty', eid, - _params['property_path'] + _o3de_fail( + 'component_not_on_entity', + f'Component "{{_params["component_type"]}}" is not on entity {{eid}}', + ) + if pair is not None: + prop = editor.EditorComponentAPIBus( + bus.Broadcast, 'GetComponentProperty', pair, _params['property_path'] ) - print(json.dumps({{'property': _params['property_path'], 'value': str(value)}})) + if hasattr(prop, 'IsSuccess') and prop.IsSuccess(): + print(json.dumps({{ + 'property': _params['property_path'], 'value': str(prop.GetValue()), + }})) + else: + _why = prop.GetError() if hasattr(prop, 'GetError') else prop + _o3de_fail( + 'get_property_failed', + f'Could not read {{_params["property_path"]}}: {{_why}}', + ) """) return await _async_run_editor_script(script) @@ -1560,35 +1555,40 @@ async def set_component_property( except ValueError: val = raw - # O3DE 2510+: resolve EntityComponentIdPair, then set property type_ids = editor.EditorComponentAPIBus( bus.Broadcast, 'FindComponentTypeIdsByEntityType', [_params['component_type']], entity.EntityType().Game ) - success = False - try: - if type_ids: - outcome = editor.EditorComponentAPIBus( - bus.Broadcast, 'GetComponentOfType', eid, type_ids[0] + _null = '00000000-0000-0000-0000-000000000000' + pair = None + if not type_ids or _null in str(type_ids[0]): + _o3de_fail( + 'component_type_not_found', + f'Component type "{{_params["component_type"]}}" not found', + ) + else: + found = editor.EditorComponentAPIBus( + bus.Broadcast, 'GetComponentOfType', eid, type_ids[0] + ) + if hasattr(found, 'IsSuccess') and found.IsSuccess(): + pair = found.GetValue() + else: + _o3de_fail( + 'component_not_on_entity', + f'Component "{{_params["component_type"]}}" is not on entity {{eid}}', ) - if hasattr(outcome, 'IsSuccess') and outcome.IsSuccess(): - pair = outcome.GetValue() - result = editor.EditorComponentAPIBus( - bus.Broadcast, 'SetComponentProperty', pair, - _params['property_path'], val - ) - print(f'Set {{_params["property_path"]}} = {{val}} (result={{result}})') - success = True - except Exception: - pass - - if not success: - # Fallback: legacy API with bare EntityId + if pair is not None: result = editor.EditorComponentAPIBus( - bus.Event, 'SetComponentProperty', eid, - _params['property_path'], val + bus.Broadcast, 'SetComponentProperty', pair, _params['property_path'], val ) - print(f'Set {{_params["property_path"]}} = {{val}} (result={{result}})') + if hasattr(result, 'IsSuccess') and result.IsSuccess(): + print(f'Set {{_params["property_path"]}} = {{val}}') + else: + _why = result.GetError() if hasattr(result, 'GetError') else result + _o3de_fail( + 'set_property_failed', + f'Could not set {{_params["property_path"]}} to {{val!r}}: {{_why}}', + ) """) return await _async_run_editor_script(script) @@ -1745,24 +1745,25 @@ async def set_transform( """Set the world transform of an entity (only provided components are changed). ``rotation`` is an ``[x, y, z, w]`` quaternion and ``scale`` an - ``[x, y, z]`` vector. Tries the gem's native ``set_transform`` request - first (gem 0.5.0 or later, works in secure mode), converting the - quaternion to the XYZ Euler degrees it takes and a uniform scale to - its single number; it returns the updated entity's JSON verbatim (the - same shape as ``get_entity``). The gem refuses an unknown entity, an - out-of-bounds position or a scale outside (0, 1000], and that is - returned as an error. Older gems, the legacy transport and a rotation - at a gimbal pole (pitch within 0.02 degrees of plus or minus 90) use the - editor-Python path, which applies the quaternion directly, keeps the - current scale when none is given, and prints - ``Transform set for entity []``. 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 the error - ``non_uniform_scale_unsupported`` instead of being applied as its - largest value. + ``[x, y, z]`` vector, the effective scale to end up with (each element in + (0, 1000]). The call goes to the AiCompanion gem's native + ``set_transform`` request (works in secure mode) and returns the updated + entity's JSON, the same shape as ``get_entity``. The gem refuses an + unknown entity or out-of-bounds values, and that is returned as an error. + + On gem API 0.5.0 and later (gem 0.6.0) the quaternion and the scale are + sent as is. A non-uniform scale is applied through the engine's + Non-uniform Scale component (each element at least 0.01); an O3DE + Transform itself holds only a uniform scale. A later uniform scale sets + that component back to 1. The reply carries ``scale`` (uniform), + ``non_uniform_scale`` and ``effective_scale``. + + On older gems the quaternion is converted to XYZ Euler degrees, a + rotation at a gimbal pole (pitch within 0.02 degrees of plus or minus 90) + goes through editor Python instead, which prints + ``Transform set for entity []``, and a non-uniform scale is refused + with ``non_uniform_scale_unsupported``. The legacy transport also uses + the editor-Python path. An all-zero quaternion is rejected before any path. """ entity_id = _validate_entity_id(entity_id) pos = _validate_vec3(position, "position") if position is not None else None @@ -1997,6 +1998,7 @@ async def get_cvar(name: str) -> str: cvar_name = _params['name'] value = None + _cvar_error = None try: import azlmbr.legacy.general as general value = general.get_cvar(cvar_name) @@ -2014,9 +2016,15 @@ async def get_cvar(name: str) -> str: if value is None: value = output except Exception as e: - value = f'Error: {{e}}' + _cvar_error = e - print(json.dumps({{'name': cvar_name, 'value': value}})) + if value is None and _cvar_error is not None: + _o3de_fail('get_cvar_failed', f'Could not read {{cvar_name}}: {{_cvar_error}}') + elif value == '(missing)': + # The engine answers this literal text for a CVAR it does not have. + _o3de_fail('cvar_not_found', f'No CVAR named {{cvar_name}}') + else: + print(json.dumps({{'name': cvar_name, 'value': value}})) """) return await _async_run_editor_script(script) @@ -2263,22 +2271,28 @@ async def get_viewport_camera() -> str: import json result = {} + _errors = [] try: pos = general.get_current_view_position() if pos is not None: result['position'] = [pos.x, pos.y, pos.z] except Exception as e: - result['error'] = str(e) + _errors.append(f'position: {e}') try: rot = general.get_current_view_rotation() if rot is not None: result['rotation'] = [rot.x, rot.y, rot.z] except Exception as e: - if 'error' not in result: - result['error'] = str(e) + _errors.append(f'rotation: {e}') - print(json.dumps(result)) + if _errors or not result: + _o3de_fail( + 'viewport_camera_unavailable', + '; '.join(_errors) or 'the editor reported no viewport camera', + ) + else: + print(json.dumps(result)) """) return await _async_run_editor_script(script) @@ -2353,16 +2367,19 @@ async def capture_viewport( ) -> str: """Capture a screenshot of the editor viewport. - Tries PySide6 widget grab first (captures the viewport widget - including UI overlays). Falls back to ``azlmbr.atom`` - ``FrameCaptureRequestBus.CaptureScreenshot`` which captures the - actual rendered frame and works on platforms where PySide6 is not - importable in the editor's embedded interpreter. + Captures the rendered frame with Atom's + ``FrameCaptureRequestBus.CaptureScreenshot``, then waits on the client + for a new, fully written file (``O3DE_CAPTURE_WAIT``, default 15 s). A + level must be open: without one the engine accepts the capture and never + writes a file, which is reported as ``capture_not_written``. A PySide6 + widget grab is tried first, but it cannot run in the editor's embedded + interpreter on O3DE 26.10.0 (``QApplication.instance()`` is ``None``). Args: output_path: File path for the screenshot (.png, .jpg, .jpeg, .bmp, or .tga). - width: Optional width to scale the screenshot to (PySide6 path only). - height: Optional height to scale the screenshot to (PySide6 path only). + width: Scale width for the PySide6 grab only; ignored by the Atom + capture, so it has no effect on 26.10.0. + height: Scale height for the PySide6 grab only; ignored like ``width``. """ output_path = output_path.strip() if not output_path: @@ -2618,8 +2635,11 @@ async def instantiate_prefab( ' The call was not made, because instantiating a missing' ' prefab crashes the editor.') else: + # Look the parent up by its text: entity.EntityId(n) does not rebuild + # an id on 26.10.0, so the parent was silently ignored. An unknown + # parent is entity_not_found. if _parent_id: - parent = entity.EntityId(_parent_id) + parent = _resolve_entity_id(_parent_id) else: parent = entity.EntityId() diff --git a/src/o3de_mcp/tools/introspection.py b/src/o3de_mcp/tools/introspection.py index 6613f90..1c651fa 100644 --- a/src/o3de_mcp/tools/introspection.py +++ b/src/o3de_mcp/tools/introspection.py @@ -96,6 +96,9 @@ async def get_bus_schema_live( mod = importlib.import_module(f'azlmbr.{{_module_name}}') bus_obj = getattr(mod, _bus_name, None) if bus_obj is None: + # Not a live answer: fall through to the stub files, whose miss + # is reported as the failure envelope. + result['source'] = 'error' result['error'] = f'Bus {{_bus_name}} not found in azlmbr.{{_module_name}}' else: events = [] @@ -141,10 +144,10 @@ async def get_bus_schema_live( async def capture_renderdoc_frame() -> str: """Trigger a RenderDoc frame capture in the O3DE editor. - Attempts to trigger a RenderDoc capture via ``GraphicsProfilerBus`` - (reflected in the BehaviorContext but not exposed as a Python bus - function in O3DE 2.7.0). If the bus call is not available, reports - the limitation and suggests manual alternatives. + Attempts to trigger a RenderDoc capture via ``GraphicsProfilerBus``. That + bus is not exposed to editor Python on O3DE 26.10.0, so there the tool + returns ``manual_required`` with the manual alternatives (F12 in + RenderDoc, or the editor's GPU capture menu). Returns: JSON with status ``ok`` if the capture was triggered, ``error`` diff --git a/src/o3de_mcp/tools/project.py b/src/o3de_mcp/tools/project.py index 77fdefd..0c209fc 100644 --- a/src/o3de_mcp/tools/project.py +++ b/src/o3de_mcp/tools/project.py @@ -251,7 +251,8 @@ def list_projects() -> str: """List all O3DE projects registered on this machine. Returns: - JSON array of project objects, or a message if none are found. + JSON array of project objects, or the error ``no_projects`` if none + are registered. """ projects = list_registered_projects() if not projects: @@ -263,7 +264,8 @@ def list_gems() -> str: """List all external gems registered on this machine. Returns: - JSON array of gem objects, or a message if none are found. + JSON array of gem objects, or the error ``no_gems`` if none are + registered. """ gems = list_registered_gems() if not gems: diff --git a/src/o3de_mcp/tools/trackview.py b/src/o3de_mcp/tools/trackview.py index a8fb346..7e20059 100644 --- a/src/o3de_mcp/tools/trackview.py +++ b/src/o3de_mcp/tools/trackview.py @@ -86,8 +86,8 @@ def register_trackview_tools(mcp: MCPServer) -> None: async def list_sequences() -> str: """List the Track View cinematic sequences in the current level. - Returns a JSON array of ``{"name": ..., "start": ..., "end": ...}`` objects, - one per sequence, with each sequence's time range in seconds. + Returns ``{"sequences": [{"name", "start", "end"}, ...], "count": N}``, + with each sequence's time range in seconds. """ script = textwrap.dedent("""\ import azlmbr.legacy.trackview as tv diff --git a/tests/test_editor.py b/tests/test_editor.py index 5a18d0c..2040ea7 100644 --- a/tests/test_editor.py +++ b/tests/test_editor.py @@ -344,6 +344,31 @@ def test_tls_enabled(self) -> None: ctx = _get_tls_context() assert ctx is not None + def test_verification_without_a_ca_file_uses_the_system_cas(self) -> None: + # A bare PROTOCOL_TLS_CLIENT context trusts no CA, so verifying without + # O3DE_EDITOR_TLS_CA used to fail every handshake. + env = {"O3DE_EDITOR_TLS": "1", "O3DE_EDITOR_TLS_VERIFY": "1"} + with patch.dict("os.environ", env, clear=True): + with patch("ssl.SSLContext.load_default_certs") as load_default: + ctx = _get_tls_context() + assert ctx is not None + load_default.assert_called_once() + + def test_a_ca_file_is_used_instead_of_the_system_cas(self, tmp_path: Path) -> None: + env = { + "O3DE_EDITOR_TLS": "1", + "O3DE_EDITOR_TLS_VERIFY": "1", + "O3DE_EDITOR_TLS_CA": str(tmp_path / "ca.pem"), + } + with patch.dict("os.environ", env, clear=True): + with ( + patch("ssl.SSLContext.load_verify_locations") as load_ca, + patch("ssl.SSLContext.load_default_certs") as load_default, + ): + _get_tls_context() + load_ca.assert_called_once_with(str(tmp_path / "ca.pem")) + load_default.assert_not_called() + # --- End-to-end protocol tests against a fake in-process server --- diff --git a/tests/test_editor_scripts.py b/tests/test_editor_scripts.py index 6db91c7..cf237c4 100644 --- a/tests/test_editor_scripts.py +++ b/tests/test_editor_scripts.py @@ -821,3 +821,74 @@ def test_a_component_multiplies_the_uniform_scale(self, surface: dict, tmp_path: got = self._scale(surface, tmp_path, 2.0, _Vec(50, 50, 1)) assert got["non_uniform_scale"] == [50.0, 50.0, 1.0] assert got["scale"] == [100.0, 100.0, 2.0] + + +class TestComponentPropertyEnvelopes: + """A refused read or write is the failure envelope, never a success line.""" + + def test_a_failed_read_is_get_property_failed(self, surface: dict, tmp_path: Path) -> None: + out = _run_tool( + "get_component_property", + surface, + tmp_path, + {("EditorComponentAPIBus", "GetComponentProperty"): Failure()}, + ) + assert json.loads(out)["code"] == "get_property_failed" + + def test_a_good_read_reports_the_value(self, surface: dict, tmp_path: Path) -> None: + out = _run_tool( + "get_component_property", + surface, + tmp_path, + {("EditorComponentAPIBus", "GetComponentProperty"): lambda *a: _Ok(4.5)}, + ) + assert json.loads(out)["value"] == "4.5" + + def test_a_refused_write_is_set_property_failed(self, surface: dict, tmp_path: Path) -> None: + out = _run_tool( + "set_component_property", + surface, + tmp_path, + {("EditorComponentAPIBus", "SetComponentProperty"): Failure()}, + ) + assert json.loads(out)["code"] == "set_property_failed" + + def test_a_good_write_says_so(self, surface: dict, tmp_path: Path) -> None: + out = _run_tool( + "set_component_property", + surface, + tmp_path, + {("EditorComponentAPIBus", "SetComponentProperty"): lambda *a: _Ok()}, + ) + assert out.startswith("Set "), out + + def test_a_component_not_on_the_entity(self, surface: dict, tmp_path: Path) -> None: + out = _run_tool( + "get_component_property", + surface, + tmp_path, + {("EditorComponentAPIBus", "GetComponentOfType"): Failure()}, + ) + assert json.loads(out)["code"] == "component_not_on_entity" + + +def test_add_component_reports_a_refused_add(surface: dict, tmp_path: Path) -> None: + out = _run_tool( + "add_component", + surface, + tmp_path, + {("EditorComponentAPIBus", "AddComponentOfType"): Failure()}, + ) + assert json.loads(out)["code"] == "add_component_failed" + + +def test_instantiate_prefab_with_an_unknown_parent(surface: dict, tmp_path: Path) -> None: + # The parent is looked up by its text; it was rebuilt with EntityId(n) and + # silently ignored before. + script = generate_script( + "instantiate_prefab", {"prefab_path": "Prefabs/t.prefab", "parent_id": "999"}, tmp_path + ) + assert script is not None + _, out = run_against_surface(script, surface, str(tmp_path), {}) + parsed = json.loads(out) + assert parsed["code"] == "entity_not_found" and "999" in parsed["message"] diff --git a/tests/test_live_editor.py b/tests/test_live_editor.py index 7d173c7..6bc0a4e 100644 --- a/tests/test_live_editor.py +++ b/tests/test_live_editor.py @@ -428,6 +428,74 @@ def test_set_and_get_cvar(self, mcp_server: MCPServer) -> None: assert isinstance(get_result, str) +class TestLiveFailureEnvelopes: + """Reads and writes that fail come back as the error envelope, not as values.""" + + def test_unknown_cvar(self, mcp_server: MCPServer) -> None: + # The engine answers the text "(missing)" for a CVAR it does not have. + got = json.loads(_run(_call(mcp_server, "get_cvar", name="no_such_cvar_o3de_mcp"))) + assert got["status"] == "error" and got["code"] == "cvar_not_found", got + + def test_component_property_round_trip_and_refusals(self, mcp_server: MCPServer) -> None: + eid = str( + json.loads(_run(_call(mcp_server, "create_entity", name="PropTest")))["entity_id"] + ) + comp = "PhysX Primitive Collider" + try: + _run(_call(mcp_server, "add_component", entity_id=eid, component_type=comp)) + trigger = "Collider Configuration|Trigger" + out = _run( + _call( + mcp_server, + "set_component_property", + entity_id=eid, + component_type=comp, + property_path=trigger, + value="true", + ) + ) + assert out.startswith("Set "), out + got = json.loads( + _run( + _call( + mcp_server, + "get_component_property", + entity_id=eid, + component_type=comp, + property_path=trigger, + ) + ) + ) + assert got["value"] == "True", got + bad = json.loads( + _run( + _call( + mcp_server, + "set_component_property", + entity_id=eid, + component_type=comp, + property_path="No|Such|Path", + value="1", + ) + ) + ) + assert bad["code"] == "set_property_failed", bad + missing = json.loads( + _run( + _call( + mcp_server, + "get_component_property", + entity_id=eid, + component_type="Mesh", + property_path="x", + ) + ) + ) + assert missing["code"] == "component_not_on_entity", missing + finally: + _run(_call(mcp_server, "delete_entity", entity_id=eid)) + + class TestLiveLevels: def test_get_level_info(self, mcp_server: MCPServer) -> None: result = _run(_call(mcp_server, "get_level_info"))