optional-skills/creative/unreal-mcp/references/tool-surface.md
How Epic's editor-embedded MCP server organizes, advertises, and executes
tools, and how to extend the surface when the shipped tools run out.
Everything here is against UE 5.8's experimental plugin (id
ModelContextProtocol); expect drift between engine versions — the live
describe_toolset schema always outranks this file.
The Unreal MCP plugin hosts the HTTP server inside the editor process
(default http://127.0.0.1:8000/mcp, loopback-only, no auth, HTTP + SSE
only — no stdio/WebSocket). It implements the protocol but ships no tools of
its own. Tools come from Toolsets — classes deriving from
UToolsetDefinition (C++) or unreal.ToolsetDefinition (Python) — collected
at startup by the Toolset Registry subsystem (sibling plugin,
auto-enabled; the registry itself ships no toolsets either). The shipped
tools live in per-domain plugins under Engine/Plugins/Experimental/ Toolsets/ — the workhorse is EditorToolset (core editor toolsets,
Python + C++) — and AllToolsets is a one-checkbox aggregator plugin
that depends on ~21 of them (verified from AllToolsets.uplugin, 5.8):
AIModule, AnimationAssistant, AutomationTest, ConfigSettings, Conversation,
DataRegistry, DataflowAgent, Editor, GameFeatures, GameplayTags, GAS,
MCPClient, Niagara, PCG, Physics, Plugin, SemanticSearch, SlateInspector,
StateTree, UMG, WorldConditions. Project plugins and Game Feature Plugins
can contribute more. Unreal MCP wraps every registered tool call as an MCP
Tool. Execution is serialized onto the game thread — one tool call at a
time, editor UI blocked while each runs.
With Enable Tool Search on (default), tools/list advertises exactly three
meta-tools:
| Meta-tool | Args | Returns |
|---|---|---|
list_toolsets | — | Registered toolset names + descriptions |
describe_toolset | toolset name | JSON Schemas for every tool in that toolset |
call_tool | toolset/tool name + arguments object | The tool's result, same turn |
Discipline:
list_toolsets once per session; re-run only after RefreshTools, plugin
changes, or reconnect.describe_toolset before first use of any toolset. Parameter names, types,
and required fields come from the schema — never from memory or this file.{"result": ...} (CVar
ModelContextProtocol.WrapPODToolResultsInObject, default true).
Structured results serialize with field-level schema.Eager mode (Enable Tool Search off) advertises every tool individually.
Under Hermes that means each tool becomes mcp_unreal_engine_<tool_name> at
session start, and hermes mcp configure unreal-engine can prune the list.
Schema payload grows with every registered toolset, and tool authors are told
NOT to rely on eager advertising — stay in tool-search mode unless a very
small fixed surface is wanted.
Verified against a running 5.8 server; these details are where naive clients die:
list_toolsets returns fully-qualified toolset names — Python:
editor_toolset.toolsets.scene.SceneTools; C++:
EditorToolset.EditorAppToolset. Epic's prose says "SceneTools"; the
registry speaks qualified names. Use them verbatim in describe_toolset
and call_tool's toolset_name.tool_name must be the short name (get_current_level,
CaptureViewport). Passing the fully-qualified tool name fails with
"Unknown tool" even though describe_toolset displays qualified names.call_tool args: {"toolset_name": ..., "tool_name": ..., "arguments": {...}}; result returns on the same turn (the HTTP response blocks until
the game thread finishes the call).TOptional parameters must be passed explicitly as null — omitting
them errors with input param "X" needs a default value. E.g.
CaptureViewport minimal call is {"captureTransform": null, "annotations": null, "bShowUI": false}.required is literal. find_actors marks name, tag,
collision_channels required even though they're semantically optional —
pass "" / [] to mean "any".b prefix intact at this
reflection layer: bUseTemperature, bAtmosphereSunLight, fogDensity,
bRealTimeCapture, mobility. Writing useTemperature does NOT error
the whole call — the response names each property that could not be set
(schema-in-error style; READ error text, it lists the exact failures and
often the full input schema).{"refPath": "<soft object path>"}
everywhere (actors, classes, components). Class refs use
/Script/Module.Class (e.g. /Script/Engine.PointLight); actor refs are
the full path (/Temp/Untitled_1.Untitled_1:PersistentLevel.DirectionalLight_UAID_...).
Spawn/find tools RETURN refPaths — capture and reuse them.ObjectTools.set_properties takes values as a JSON string, not
an object: {"instance": {"refPath": ...}, "values": "{\"intensity\": 10.0}"}. get_properties likewise returns a JSON
string inside returnValue. Double-encode/decode accordingly.{"returnValue": ...} inside the
text content block.initialize → plain JSON response + Mcp-Session-Id header you must
echo on every subsequent request; notifications/initialized → 202
empty; tools/call → text/event-stream: the result arrives as an
event: message + data: <jsonrpc> frame only when the game thread
finishes. A client that treats the response as plain JSON reads an empty
body. Send Accept: application/json, text/event-stream always.The registry is project-dependent; describe_toolset on the live server is
the only source of truth for schemas. The core surface below is verified
against EditorToolset's source in the 5.8 install (Python:
.../EditorToolset/Content/Python/editor_toolset/toolsets/; C++:
EditorAppToolset.h).
EditorToolset plugin (the core), Python toolsets (live-verified on 5.8;
qualified prefix editor_toolset.toolsets.<module>.<Class>):
| Toolset | Verified tools (subset) |
|---|---|
scene.SceneTools | load_level, get_current_level, find_actors (by name/type/tag/bounds), add_to_scene_from_class, add_to_scene_from_asset, remove_from_scene, save_actor, create_level_instance, folders |
actor.ActorTools | get_label/set_label, tags, get_actor_transform/set_actor_transform (xform fields optional = "don't change"), parenting, components |
primitive.PrimitiveTools | add_cube (dimensions), add_sphere (radius), add_cylinder/add_cone (radius+height) — adds StaticMeshComponents with local_transform to a host actor: spawn /Script/Engine.Actor, then compose. The fastest blocking path, zero asset dependencies |
object.ObjectTools | list_properties (returns full JSON schema of every property), get_properties/set_properties (JSON-string values), reset_properties (restore defaults — also your rollback), get_class, search_subclasses |
material_instance.MaterialInstanceTools | create, list_parameters, get/set_scalar_parameter, get/set_vector_parameter |
asset.AssetTools | find_assets, load_asset, exists, save_assets, is_dirty, get_dependencies/get_referencers (check before delete!), delete, move, duplicate, folders, read_file/write_file (project-scoped) |
blueprint.BlueprintTools (+ dsl/layout/node) | Blueprint authoring |
material.MaterialTools, static_mesh.StaticMeshTools, texture.TextureTools, data_table.DataTableTools, … | per-asset-type operations |
programmatic.ProgrammaticToolset | the batching escape hatch — see below |
EditorToolset.EditorAppToolset (C++, same plugin) — the agent's eyes
(full live list): CaptureViewport, CaptureEditorImage,
CaptureAssetImage, GetCameraTransform/SetCameraTransform,
GetSelectedActors/SelectActors/FocusOnActors/GetVisibleActors,
WorldPosToScreenCoords/ScreenCoordsToWorld,
GetSelectedAssets/SelectAssets,
GetContentBrowserPath/SetContentBrowserPath, OpenEditorForAsset,
GetOpenAssets, SearchCVars, StartPIE/StopPIE/IsPIERunning.
CaptureViewport specifics (live-verified): args {"captureTransform": <transform-or-null>, "annotations": <config-or-null>, "bShowUI": false}.
Returns base64 PNG (decode + save it yourself) plus camera
location/rotation/FOV. captureTransform captures from any pose WITHOUT
moving the user's viewport — use it as a virtual camera. Annotation config
{"gridSpacingCm": 500, "gridExtentCm": 3000, "gridHeight": <ground Z>, "labelActors": true} overlays a projected ground grid and actor callouts;
grid coordinate labels are in METERS (world cm ÷ 100). Use annotated
captures for placement work, clean ones for beauty checks.
Also confirmed live: ToolsetRegistry.AgentSkillToolset,
EditorToolset.LogsToolset (read Output Log + set verbosity — useful for
self-debugging), SemanticSearchToolset (hybrid vector+BM25 asset search),
five NiagaraToolsets.NiagaraToolset_* groups, PCGToolset (+Spatial),
UMGToolSet, three GASToolsets.*, AutomationTestToolset,
ConfigSettingsToolset (read/write Project Settings & Editor Preferences
sections by schema — the remote path to exposure defaults, rendering
settings, etc.), SlateInspectorToolset, PluginToolset,
animation_toolset.toolsets.sequencer.SequencerTools + keyframing/
controlrig/outliner siblings, aimodule_toolset BehaviorTreeTools,
state_tree_toolset StateTreeTools, and more — 67 toolsets on a blank
project with AllToolsets enabled.
Known gap: no mesh-modelling tools — spawn/place/instance existing meshes,
yes; author new geometry, no. The supported route to parametric geometry is
a custom Python toolset wrapping Geometry Script (UDynamicMesh:
append box/cylinder/sphere, booleans, then Create New Static Mesh Asset from Mesh to bake an SM_ asset). For organic/sculpted meshes, model in
Blender (blender-mcp skill) and import.
First-session move: list_toolsets, then describe_toolset each group you
plan to use, and keep those schemas in working memory for the session.
The serial-call rule makes N-step edits slow over the wire. The shipped
answer is ProgrammaticToolset (verified in programmatic.py):
get_execution_environment — mandatory first call (the tool's own
docstring requires it); returns the allowed modules, script constraints,
and usage instructions.execute_tool_script(script) — runs a sandboxed Python script that
defines run() -> dict. Inside, you call other registered tools
programmatically and glue them with logic — one MCP round-trip for a
whole loop (e.g. spawn 20 actors with computed transforms).Sandbox facts (from source): allowed imports are json, math,
datetime, copy, re, time only; open() is restricted to
project-contained paths; scripts run inside an editor transaction scope
(undo-friendly); it is tool orchestration, NOT general Python — arbitrary
unreal.* calls are not the contract. Data returns via run()'s dict.
Use it whenever a recipe loop exceeds ~5 homogeneous calls; keep one-off
edits as plain call_tool.
Projects and plugins can register Agent Skills — named instruction
bundles for project-specific conventions and workflows (naming schemes,
folder layout, canonical multi-step sequences). They are NOT listed by
list_toolsets; reach them through call_tool:
AgentSkillToolset.ListSkills → names + descriptions of registered
skills.AgentSkillToolset.GetSkills on it → full
instructions, then FOLLOW THEM — a project skill exists precisely
because the project's way differs from the obvious way, and it takes
precedence over this skill's generic defaults.Check at the start of unfamiliar work in any project, not just once ever.
An agent that can't see the viewport is flying blind. In order of preference:
EditorAppToolset.CaptureViewport (confirmed shipped) — returns the
image through MCP as base64 PNG with camera metadata; supports capturing
from an arbitrary transform without disturbing the user's viewport, and
an optional annotation overlay (world-space meter grid + actor callouts)
for spatial-placement work. This is the default verification tool.HighResShot via any console/exec tool when you need
resolutions beyond the viewport: HighResShot 3840x2160 writes to
<Project>/Saved/Screenshots/<Platform>/ on the EDITOR host's
filesystem; read the file back (same machine) with vision_analyze.Always vision_analyze the capture and art-direct against the brief before
declaring a milestone done.
Editor Preferences > General > Model Context Protocol:
| Property | Default | Notes |
|---|---|---|
| Auto Start Server | false | Turn on for frictionless sessions |
| Server Port Number | 8000 | Change on conflict; mirror in Hermes config url |
| Server URL Path | /mcp | Same |
| Enable Tool Search | true | Keep on (see above) |
Console commands (editor console, backtick):
| Command | Effect |
|---|---|
ModelContextProtocol.StartServer [port] | Start server (optional port override) |
ModelContextProtocol.StopServer | Stop server, close all sessions |
ModelContextProtocol.RefreshTools | Re-poll toolset providers — run after authoring/hot-reload/Game-Feature activation |
ModelContextProtocol.GenerateClientConfig <Client|All> | Write client config files (ClaudeCode/Cursor/VSCode/Gemini/Codex) — NOT used for Hermes |
Command-line flags for launching the editor pre-configured:
-ModelContextProtocolStartServer (force start regardless of preference),
-ModelContextProtocolPort=N.
Console variables:
| CVar | Default | Notes |
|---|---|---|
ModelContextProtocol.WrapPODToolResultsInObject | true | Primitive results wrapped as {"result": ...} |
ModelContextProtocol.AudioResultOggFormat | false | OGG instead of WAV for audio results |
ModelContextProtocol.ProgressIntervalSeconds | 1.0 | Min interval between progress notifications |
ModelContextProtocol.PaginationPageSize | 0 | 0 = no pagination of list results |
ModelContextProtocol.EnableAnalytics | true | Epic telemetry gate |
Log LogModelContextProtocol Verbose in the editor
console.npx @modelcontextprotocol/inspector, point at
http://127.0.0.1:8000/mcp, transport "Streamable HTTP") lists every
advertised tool with schemas and offers form-based invocation — isolates
"server broken" from "agent calling it wrong".ModelContextProtocol.RefreshTools, then reconnect (new Hermes
session) if schemas still look stale.When shipped tools don't cover an operation, the supported path is authoring a project toolset — NOT trying to smuggle arbitrary code through unrelated tools. Python toolsets are first-class and hot-loadable, so prefer them.
Any enabled plugin's Content/Python/ directory (or the project's) can hold
toolset modules; the registry discovers them at startup. Shape (mirrors
Epic's shipped ActorTools):
import unreal
import toolset_registry
@unreal.uclass()
class MySceneTools(unreal.ToolsetDefinition):
"""One-line toolset description — surfaces to the agent in list_toolsets."""
@toolset_registry.tool_call
@staticmethod
def take_viewport_screenshot(filename: str, width: int, height: int) -> str:
"""Capture the active viewport to Saved/Screenshots.
Args:
filename: Base filename without extension.
width: Output width in pixels.
height: Output height in pixels.
Returns:
Absolute path the screenshot will be written to.
"""
...
Conventions that matter (they generate the schema the agent sees):
@unreal.uclass() on the class; inherit unreal.ToolsetDefinition.@toolset_registry.tool_call + @staticmethod.
Functions without the decorator stay private.str, bool, list[str], unreal.Actor, dataclasses) drive
the JSON Schema; Google-style docstrings (Args:/Returns:) become the
parameter descriptions. Write them with API-surface care.print()/stdout go to the UE log, not back over MCP.After authoring: ModelContextProtocol.RefreshTools in the editor console,
then re-list_toolsets from Hermes. Users on Claude Code can scaffold with
the create-toolset skill from Epic's unreal-mcp plugin pack; the
conventions above still apply.
Derive from UToolsetDefinition, mark the class UCLASS(BlueprintType, Hidden), expose static UFUNCTION(meta = (AICallable)) methods; doc
comments reflect into schemas. Use only when Python can't reach the API,
when reflected USTRUCT signatures are needed, or when the Python boundary
cost matters. Exclude a function with meta = (AIIgnore). Live Coding
propagates edited function bodies, but NEW UFUNCTIONs require a full
editor restart. There is also a direct-registration path
(IModelContextProtocolTool + IModelContextProtocolModule::AddTool()) for
runtime-shaped tools; caller owns deregistration.
The server is editor-hosted by default but not editor-only: runtime modules
can host it in cooked builds via IModelContextProtocolModule::StartServer().
The Toolset Registry adapter (and the three tool-search meta-tools) are
editor-only, though — cooked-build tools must be registered explicitly
through AddTool() and are advertised eagerly. MCP Resources and Prompts are
not advertised by any shipping toolset.
Origin
headers rejected; no auth layer. Not safe beyond the local machine.UFUNCTION declarations.