From 44710ea3eee03fae3809f700f4ce88abbecc52d7 Mon Sep 17 00:00:00 2001 From: Nick Schuetz Date: Fri, 9 Oct 2026 09:43:48 -0500 Subject: [PATCH] Fix what the pre-release audit found, in code and docs A full audit of every doc and docstring against the code before 0.6.0 found behavior that was wrong, not just described wrong, so those are fixed in code: - 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 an unknown CVAR with the engine's text "(missing)" as its value and a failed read as "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; removed. - instantiate_prefab rebuilt parent_id with entity.EntityId(n), invalid on 26.10.0, so the parent was silently ignored; it now looks the parent up. - With TLS verification on and no O3DE_EDITOR_TLS_CA, no CA was trusted and every handshake failed; the system CA store is now loaded, as documented. Docstrings and docs: set_transform, get_entity, list_sequences, the animation module, capture_viewport, capture_renderdoc_frame and the project listers now say what the code does. The tool reference fixes the viewport camera, capture, save_prefab, list_sequences, asset and component examples and the fast-fail wording. AGENTS.md maps its tool groups onto get_capabilities' groups. Recipes, the examples and the skill no longer rebuild entity ids from text (they use the tools, or a SearchBus lookup), use the current tool counts and gem versions, and the PhysX trigger and shape property paths they now show were checked live. CONTRIBUTING.md lists the conventions new tools follow. Tests: surface tests for each new envelope path and the prefab parent, TLS context tests, and live tests for the unknown CVAR and a collider property round trip with its refusals. Live suite 58 passed against AiCompanion gem 0.6.0 (API 0.5.0). --- AGENTS.md | 5 +- CHANGELOG.md | 26 +- CLAUDE.md | 4 +- CONTRIBUTING.md | 7 +- README.md | 2 +- docs/architecture.md | 2 +- docs/recipes.md | 64 ++--- docs/tool-reference.md | 116 +++++---- examples/02_build_scene.md | 15 +- examples/03_physics_playground.md | 25 +- examples/04_scripted_game.md | 35 +-- examples/06_cli_only_workflow.md | 4 +- examples/08_mcp_inspector.md | 11 +- .../reference/editor-automation.md | 34 ++- .../reference/tooling-and-copr.md | 4 +- src/o3de_mcp/tools/animation.py | 6 +- src/o3de_mcp/tools/editor.py | 236 ++++++++++-------- src/o3de_mcp/tools/introspection.py | 11 +- src/o3de_mcp/tools/project.py | 6 +- src/o3de_mcp/tools/trackview.py | 4 +- tests/test_editor.py | 25 ++ tests/test_editor_scripts.py | 71 ++++++ tests/test_live_editor.py | 68 +++++ 23 files changed, 489 insertions(+), 292 deletions(-) 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"))