19 KiB
Unreal MCP — Tool Surface Reference
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.
Architecture in one paragraph
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.
Tool-search mode (the default contract)
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_toolsetsonce per session; re-run only afterRefreshTools, plugin changes, or reconnect.describe_toolsetbefore first use of any toolset. Parameter names, types, and required fields come from the schema — never from memory or this file.- Results: primitive results arrive wrapped as
{"result": ...}(CVarModelContextProtocol.WrapPODToolResultsInObject, default true). Structured results serialize with field-level schema. - Errors come back as tool-call errors with the engine-side message — read them; they usually name the offending parameter or missing asset.
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.
call_tool dispatch semantics (live-verified, 5.8)
Verified against a running 5.8 server; these details are where naive clients die:
list_toolsetsreturns 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 indescribe_toolsetandcall_tool'stoolset_name.tool_namemust be the short name (get_current_level,CaptureViewport). Passing the fully-qualified tool name fails with "Unknown tool" even thoughdescribe_toolsetdisplays qualified names.call_toolargs:{"toolset_name": ..., "tool_name": ..., "arguments": {...}}; result returns on the same turn (the HTTP response blocks until the game thread finishes the call).TOptionalparameters must be passed explicitly asnull— omitting them errors withinput param "X" needs a default value. E.g.CaptureViewportminimal call is{"captureTransform": null, "annotations": null, "bShowUI": false}.- Schema
requiredis literal.find_actorsmarksname,tag,collision_channelsrequired even though they're semantically optional — pass""/[]to mean "any". - Property names are camelCase with UE's
bprefix intact at this reflection layer:bUseTemperature,bAtmosphereSunLight,fogDensity,bRealTimeCapture,mobility. WritinguseTemperaturedoes 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). - Object references travel as
{"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_propertiestakesvaluesas a JSON string, not an object:{"instance": {"refPath": ...}, "values": "{\"intensity\": 10.0}"}.get_propertieslikewise returns a JSON string insidereturnValue. Double-encode/decode accordingly.- Primitive results arrive wrapped as
{"returnValue": ...}inside the text content block.
HTTP wire behavior (for raw clients / debugging)
initialize→ plain JSON response +Mcp-Session-Idheader you must echo on every subsequent request;notifications/initialized→ 202 empty;tools/call→text/event-stream: the result arrives as anevent: message+data: <jsonrpc>frame only when the game thread finishes. A client that treats the response as plain JSON reads an empty body. SendAccept: application/json, text/event-streamalways.
Shipped toolsets
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 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.
ProgrammaticToolset — sanctioned batching
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 definesrun() -> 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.
Project Agent Skills (AgentSkillToolset)
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.- If one matches the task,
AgentSkillToolset.GetSkillson 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.
Seeing your work: screenshots and captures
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.- Console
HighResShotvia any console/exec tool when you need resolutions beyond the viewport:HighResShot 3840x2160writes to<Project>/Saved/Screenshots/<Platform>/on the EDITOR host's filesystem; read the file back (same machine) withvision_analyze. - Custom toolset escape hatch for anything else (e.g. camera-actor framed captures with MRQ-quality settings).
Always vision_analyze the capture and art-direct against the brief before
declaring a milestone done.
Plugin configuration reference
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 |
Debugging the connection
- Output Log at editor startup logs bind address/port/path — first stop when the server seems absent. Port-in-use and missing-dependency failures surface here.
- Log verbosity:
Log LogModelContextProtocol Verbosein the editor console. - MCP Inspector (
npx @modelcontextprotocol/inspector, point athttp://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". - After Live Coding / authoring: connected clients can hold stale
schemas.
ModelContextProtocol.RefreshTools, then reconnect (new Hermes session) if schemas still look stale.
Extending the surface: custom toolsets
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.
Python toolset (recommended)
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; inheritunreal.ToolsetDefinition.- Class docstring = toolset description; write it for an agent audience.
- Each advertised function:
@toolset_registry.tool_call+@staticmethod. Functions without the decorator stay private. - Type hints (
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. - Small, single-responsibility tools with structured return types beat
mega-tools returning prose. Data leaves the tool via its RETURN VALUE —
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.
C++ toolset
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.
Runtime and cooked builds
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.
Known limitations (5.8, experimental)
- HTTP + SSE transports only; loopback-only listener; non-loopback
Originheaders rejected; no auth layer. Not safe beyond the local machine. - Serial game-thread execution: overlapping calls unsupported; editor UI blocks during each call.
- Feature-incomplete by Epic's own labeling; APIs and data formats subject to change without notice.
- Live Coding does not propagate new
UFUNCTIONdeclarations.