diff --git a/.claude/skills/mcp-scene-iteration/SKILL.md b/.claude/skills/mcp-scene-iteration/SKILL.md
index 8632b6991f3..1e6179ed585 100644
--- a/.claude/skills/mcp-scene-iteration/SKILL.md
+++ b/.claude/skills/mcp-scene-iteration/SKILL.md
@@ -123,6 +123,7 @@ Paths are relative to this skill's directory; requires curl + python3; pass `-p
## Interaction testing
- `click_entity` presses a pointer button on a scene entity (get ids from `list_scene_entities`). The target needs a `PointerEvents` component and a collider; the aim is validated by a real camera-origin raycast, so occluders return `hit:false` + `blockedBy*` (reposition and retry) and the entity's `maxDistance` (default 10 m) applies — get close first. `upRayMissed: true` means the target moved between press and release (e.g. a door starting to swing) and the release was delivered with the press-frame hit. For GLTF entities whose collider sits away from the pivot, pass an explicit `x/y/z` aim point. The player must be standing on the scene's parcel — off-parcel clicks fail with "no running current scene".
+- `press_input_action` sends an input action with no target, for scenes that poll input globally (`inputSystem.isTriggered(InputAction.IA_PRIMARY, ...)` with no entity) — `click_entity` cannot reach those, because every one of its paths needs a collider to hit. `action` takes the wire spelling of the SDK action (`primary`, `secondary`, `pointer`, `action_3`…`action_6`, `jump`, `forward`, …); `press` holds it for `holdSec` (default 0.2) and releases it on a later scene tick, so a scene reading `isPressed` sees a real hold. Use `down`/`up` for a hold you drive yourself — a `down` with no matching `up` leaves the scene believing the button is held, and nothing sequences the two calls, so a pair sent back to back can land in one scene tick and cancel out. Prefer `press` whenever the hold length is known up front. Only one action is in flight at a time.
- `walk` moves relative to the camera and requires an explicit direction: pass `directionY: 1` for forward (`directionX` strafes); omitting both errors with "directionX and directionY must not both be zero".
- Collider checks beat pixels for physics (cross-examine): `look_at` straight at the target, `walk` forward, then compare `get_player_state` positions to prove passage or blockage.
- Trigger areas fire `onTriggerEnter` immediately after `reload_scene` if the player is already standing inside one — reposition the player outside all triggers before testing enter/exit sequencing (and treat post-reload trigger logs as stale state, not gameplay).
diff --git a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/DynamicWorldContainer.cs b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/DynamicWorldContainer.cs
index 4b71e20537b..6efe7114679 100644
--- a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/DynamicWorldContainer.cs
+++ b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/DynamicWorldContainer.cs
@@ -863,6 +863,7 @@ await MapRendererContainer
bootstrapContainer.DiagnosticsContainer,
exposedGlobalDataContainer.ExposedCameraData,
staticContainer.EntityCollidersGlobalCache,
+ exposedGlobalDataContainer.GlobalInputEvents,
coroutineRunner,
globalWorld,
localSceneDevelopment));
diff --git a/Explorer/Assets/DCL/Interaction/Systems/PrepareGlobalInputEventsSystem.cs b/Explorer/Assets/DCL/Interaction/Systems/PrepareGlobalInputEventsSystem.cs
index 0958d26414f..668f5bf9755 100644
--- a/Explorer/Assets/DCL/Interaction/Systems/PrepareGlobalInputEventsSystem.cs
+++ b/Explorer/Assets/DCL/Interaction/Systems/PrepareGlobalInputEventsSystem.cs
@@ -8,6 +8,12 @@
namespace DCL.Interaction.PlayerOriginated.Systems
{
+ ///
+ /// Refills from scratch each frame, for the scene worlds to drain in
+ /// their PreRendering group later in the same frame. Anything else that adds an entry — an automation
+ /// driver such as McpInputActionSystem — must therefore run after this system and within the same frame;
+ /// moving the clear, or this system's group, silently drops those entries.
+ ///
[UpdateInGroup(typeof(PresentationSystemGroup))]
[LogCategory(ReportCategory.INPUT)]
public partial class PrepareGlobalInputEventsSystem : BaseUnityLoopSystem
diff --git a/Explorer/Assets/DCL/McpServer/Components/McpInputActionIntent.cs b/Explorer/Assets/DCL/McpServer/Components/McpInputActionIntent.cs
new file mode 100644
index 00000000000..4a5f4fb8f35
--- /dev/null
+++ b/Explorer/Assets/DCL/McpServer/Components/McpInputActionIntent.cs
@@ -0,0 +1,79 @@
+using Cysharp.Threading.Tasks;
+using DCL.ECSComponents;
+using DCL.McpServer.Core;
+
+namespace DCL.McpServer.Components
+{
+ ///
+ /// Present on the player entity while an agent-requested global input action awaits delivery.
+ /// McpInputActionSystem publishes the requested edge(s) into the same per-frame GlobalInputEvents buffer
+ /// the real key bindings feed, so the current scene receives an entity-less PBPointerEventsResult on its
+ /// root entity — the form an SDK7 scene reads through inputSystem.isTriggered / isPressed without any
+ /// entity being involved. A request the simulation never picks up is removed by the tool-side timeout.
+ ///
+ public struct McpInputActionIntent : IMcpEcsRequest
+ {
+ public readonly InputAction Action;
+
+ ///
+ /// Pins delivery to one scene, matched by the definition id get_scene_state reports: the edge fails
+ /// instead of landing in whatever scene is current if the player moved after the request was made.
+ /// Null accepts the current scene as is.
+ ///
+ public readonly string? SceneId;
+
+ /// The single edge to publish, or PetDown when asks for a release too.
+ public readonly PointerEventType EventType;
+
+ ///
+ /// Seconds the button stays down before the matching release is published; null for a lone edge.
+ /// The release is owned by the system rather than by the caller, so an agent that disconnects mid-hold
+ /// cannot leave the scene believing the button is still held.
+ ///
+ public readonly float? HoldSeconds;
+
+ public UniTaskCompletionSource? Completion { get; set; }
+
+ /// UnityEngine.Time.time at which the press was published; null until it has been, which is also what tells the system a release is still owed.
+ public float? PressTime;
+
+ /// Scene tick the press is taken to have been stamped with; the release waits for the scene to pass it.
+ public uint? PressTick;
+
+ public McpInputActionIntent(InputAction action, string? sceneId, PointerEventType eventType, float? holdSeconds = null)
+ {
+ Action = action;
+ SceneId = sceneId;
+ EventType = eventType;
+ HoldSeconds = holdSeconds;
+ Completion = null;
+ PressTime = null;
+ PressTick = null;
+ }
+ }
+
+ /// Wire-facing outcome of a global input action, serialized by the press_input_action tool.
+ public struct McpInputActionResult
+ {
+ ///
+ /// The edge was published to a running current scene. That is as far as the client can attest: what
+ /// the scene's JavaScript does with it, or whether it polls that action at all, is not observable here.
+ ///
+ public bool Delivered;
+
+ public string? FailureReason;
+
+ /// Definition id of the scene the edge was published to, when one was resolved.
+ public string? SceneId;
+
+ /// How long the button was actually held, for a press that completed both legs.
+ public float HeldSeconds;
+
+ ///
+ /// The press was published but its release was not (the scene stopped being current or stopped
+ /// running mid-hold, or a newer request preempted this one): the scene still sees the button as
+ /// held, exactly as it would for a real key whose scene was torn down mid-press.
+ ///
+ public bool ReleaseMissed;
+ }
+}
diff --git a/Explorer/Assets/DCL/McpServer/Components/McpInputActionIntent.cs.meta b/Explorer/Assets/DCL/McpServer/Components/McpInputActionIntent.cs.meta
new file mode 100644
index 00000000000..7e89f0914ac
--- /dev/null
+++ b/Explorer/Assets/DCL/McpServer/Components/McpInputActionIntent.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: c364ee9c87664633929ef563944bef3e
\ No newline at end of file
diff --git a/Explorer/Assets/DCL/McpServer/Core/McpInputAction.cs b/Explorer/Assets/DCL/McpServer/Core/McpInputAction.cs
new file mode 100644
index 00000000000..017107c6241
--- /dev/null
+++ b/Explorer/Assets/DCL/McpServer/Core/McpInputAction.cs
@@ -0,0 +1,44 @@
+using DCL.ECSComponents;
+
+namespace DCL.McpServer.Core
+{
+ ///
+ /// Wire-facing spelling of , so a tool's schema and the parsing of its argument
+ /// derive from one enum (see ): "pointer" ↔ IA_POINTER, "action_5" ↔
+ /// IA_ACTION_5. It mirrors member for member — all of which the production
+ /// input map in GlobalInteractionPlugin binds, so a scene may read any of them; a tool that accepts only
+ /// a subset narrows its schema through the allowed parameter of
+ /// rather than declaring an enum of its own.
+ ///
+ /// Members are declared in the protobuf's own order, so each lands on its counterpart's value and
+ /// converts by cast instead of a mapping table that could
+ /// drift. Nothing in the language enforces that alignment — PressInputActionToolShould does, by
+ /// checking every member against the protobuf member its name spells.
+ ///
+ ///
+ public enum McpInputAction : byte
+ {
+ POINTER,
+ PRIMARY,
+ SECONDARY,
+ ANY,
+ FORWARD,
+ BACKWARD,
+ RIGHT,
+ LEFT,
+ JUMP,
+ WALK,
+ ACTION_3,
+ ACTION_4,
+ ACTION_5,
+ ACTION_6,
+ MODIFIER,
+ }
+
+ public static class McpInputActionExtensions
+ {
+ /// Valid by construction: the members are declared with their protobuf values.
+ public static InputAction ToInputAction(this McpInputAction action) =>
+ (InputAction)action;
+ }
+}
diff --git a/Explorer/Assets/DCL/McpServer/Core/McpInputAction.cs.meta b/Explorer/Assets/DCL/McpServer/Core/McpInputAction.cs.meta
new file mode 100644
index 00000000000..509ecd9648f
--- /dev/null
+++ b/Explorer/Assets/DCL/McpServer/Core/McpInputAction.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 195b0bb006d84d05907a3edf61e2cec8
\ No newline at end of file
diff --git a/Explorer/Assets/DCL/McpServer/Systems/McpInputActionSystem.cs b/Explorer/Assets/DCL/McpServer/Systems/McpInputActionSystem.cs
new file mode 100644
index 00000000000..9f7ca97da72
--- /dev/null
+++ b/Explorer/Assets/DCL/McpServer/Systems/McpInputActionSystem.cs
@@ -0,0 +1,160 @@
+using Arch.Core;
+using Arch.SystemGroups;
+using Arch.SystemGroups.DefaultSystemGroups;
+using DCL.Diagnostics;
+using DCL.ECSComponents;
+using DCL.Interaction.PlayerOriginated;
+using DCL.Interaction.PlayerOriginated.Systems;
+using DCL.McpServer.Components;
+using DCL.McpServer.Core;
+using ECS.Abstract;
+using ECS.SceneLifeCycle;
+using SceneRunner.Scene;
+using System.Diagnostics.CodeAnalysis;
+
+namespace DCL.McpServer.Systems
+{
+ ///
+ ///
+ /// Delivers an agent-requested global input action while an is
+ /// present on the player entity. The edge is published into the very
+ /// buffer the key bindings feed through , so the current
+ /// scene's WritePointerEventResultsSystem turns it into an entity-less PBPointerEventsResult on the
+ /// scene root entity — the shape an SDK7 scene reads with inputSystem.isTriggered / isPressed when no
+ /// entity is involved. Nothing is raycast and no collider has to qualify: unlike
+ /// , this path has no target.
+ ///
+ ///
+ /// The buffer is refilled from scratch every frame, so the entry must be added after
+ /// cleared it and before the scene worlds run their
+ /// PreRendering group later in the same frame. Publishing is as far as this system can see: the scene
+ /// writer drops the whole buffer for a frame in which an entity-targeted result was written instead
+ /// (the same suppression real input is subject to), so a request delivered concurrently with a
+ /// click_entity on a qualifying entity can be swallowed.
+ ///
+ ///
+ /// A press owns its release: the tool asks for a hold duration and the system publishes the PetUp
+ /// itself, so an agent that disconnects mid-hold cannot leave the scene believing the button is still
+ /// down. The release is withheld until the scene has advanced past the tick the press was stamped
+ /// with — the SDK keeps pointer results in a set keyed by that tick, and two edges sharing one key
+ /// collapse into an ambiguous button state.
+ ///
+ ///
+ [UpdateInGroup(typeof(PresentationSystemGroup))]
+ [UpdateAfter(typeof(PrepareGlobalInputEventsSystem))]
+ [LogCategory(ReportCategory.MCP)]
+ public partial class McpInputActionSystem : BaseUnityLoopSystem
+ {
+ private readonly IScenesCache scenesCache;
+ private readonly GlobalInputEvents globalInputEvents;
+ private readonly Entity playerEntity;
+
+ internal McpInputActionSystem(World world,
+ IScenesCache scenesCache,
+ GlobalInputEvents globalInputEvents,
+ Entity playerEntity) : base(world)
+ {
+ this.scenesCache = scenesCache;
+ this.globalInputEvents = globalInputEvents;
+ this.playerEntity = playerEntity;
+ }
+
+ protected override void Update(float t)
+ {
+ ref McpInputActionIntent intent = ref World.TryGetRef(playerEntity, out bool exists);
+
+ if (!exists)
+ return;
+
+ if (!TryResolve(in intent, out ISceneFacade? scene))
+ return;
+
+ if (intent.PressTime is { } pressTime)
+ Release(ref intent, scene, pressTime);
+ else
+ Publish(ref intent, scene);
+ }
+
+ /// Picks the scene the edge must be delivered to, or completes the request with the reason no delivery is possible.
+ private bool TryResolve(in McpInputActionIntent intent, [NotNullWhen(true)] out ISceneFacade? scene)
+ {
+ scene = scenesCache.CurrentScene.Value;
+
+ if (scene == null || !scene.SceneStateProvider.IsCurrent || scene.SceneStateProvider.IsNotRunningState())
+ {
+ scene = null;
+ Fail(in intent, "no running current scene to deliver the input action to");
+ return false;
+ }
+
+ if (intent.SceneId != null && scene.SceneData.SceneEntityDefinition.id != intent.SceneId)
+ {
+ string reason = $"the request is pinned to scene '{intent.SceneId}' but the current scene is '{scene.Info.Name}' (did the player move?)";
+ scene = null;
+ Fail(in intent, reason);
+ return false;
+ }
+
+ return true;
+ }
+
+ /// Publishes the requested edge; a press then waits out its hold, a lone edge completes here.
+ private void Publish(ref McpInputActionIntent intent, ISceneFacade scene)
+ {
+ globalInputEvents.Add(new IGlobalInputEvents.Entry(intent.Action, intent.EventType));
+
+ if (intent.HoldSeconds.HasValue)
+ {
+ intent.PressTime = UnityEngine.Time.time;
+ return;
+ }
+
+ McpEcsRequest.CompleteAndRemove(World, playerEntity, intent, Delivered(scene));
+ }
+
+ /// Publishes the PetUp of a held press once both the hold and the tick gate have elapsed.
+ private void Release(ref McpInputActionIntent intent, ISceneFacade scene, float pressTime)
+ {
+ // The scene stamps the press between the frame it was published on and this one, so the tick the
+ // release is gated against is read a frame late: never too early, so the two edges cannot share it.
+ // The capturing frame fails the gate below by construction, which is what buys that frame.
+ intent.PressTick ??= scene.SceneStateProvider.TickNumber;
+
+ float heldSeconds = UnityEngine.Time.time - pressTime;
+
+ if (heldSeconds < intent.HoldSeconds || scene.SceneStateProvider.TickNumber <= intent.PressTick)
+ return;
+
+ globalInputEvents.Add(new IGlobalInputEvents.Entry(intent.Action, PointerEventType.PetUp));
+
+ McpInputActionResult result = Delivered(scene);
+ result.HeldSeconds = heldSeconds;
+ McpEcsRequest.CompleteAndRemove(World, playerEntity, intent, result);
+ }
+
+ ///
+ /// Completes the request with the reason it could not be delivered. A press already published counts
+ /// as delivered whatever rejects the rest of it — only its release is lost, and the scene goes on
+ /// seeing the button held. The intent is copied out before the structural removal, so the caller's
+ /// ref must not be touched afterwards.
+ ///
+ private void Fail(in McpInputActionIntent intent, string reason)
+ {
+ bool pressed = intent.PressTime.HasValue;
+
+ McpEcsRequest.CompleteAndRemove(World, playerEntity, intent, new McpInputActionResult
+ {
+ Delivered = pressed,
+ ReleaseMissed = pressed,
+ FailureReason = reason,
+ });
+ }
+
+ private static McpInputActionResult Delivered(ISceneFacade scene) =>
+ new ()
+ {
+ Delivered = true,
+ SceneId = scene.SceneData.SceneEntityDefinition.id,
+ };
+ }
+}
diff --git a/Explorer/Assets/DCL/McpServer/Systems/McpInputActionSystem.cs.meta b/Explorer/Assets/DCL/McpServer/Systems/McpInputActionSystem.cs.meta
new file mode 100644
index 00000000000..3858d045a09
--- /dev/null
+++ b/Explorer/Assets/DCL/McpServer/Systems/McpInputActionSystem.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 5690de7803c64a68ac8b4a35ec98daf3
\ No newline at end of file
diff --git a/Explorer/Assets/DCL/McpServer/Systems/McpServerPlugin.cs b/Explorer/Assets/DCL/McpServer/Systems/McpServerPlugin.cs
index b09b1e74ab9..cce9e0208f1 100644
--- a/Explorer/Assets/DCL/McpServer/Systems/McpServerPlugin.cs
+++ b/Explorer/Assets/DCL/McpServer/Systems/McpServerPlugin.cs
@@ -4,6 +4,7 @@
using DCL.CharacterCamera;
using DCL.Chat.MessageBus;
using DCL.Diagnostics;
+using DCL.Interaction.PlayerOriginated;
using DCL.Interaction.Utility;
using DCL.McpServer.Core;
using DCL.McpServer.Tools;
@@ -45,6 +46,7 @@ public class McpServerPlugin : IDCLGlobalPluginWithoutSettings
private readonly Arch.Core.World globalWorld;
private readonly IGlobalWorldActions globalWorldActions;
private readonly IEntityCollidersGlobalCache entityCollidersGlobalCache;
+ private readonly GlobalInputEvents globalInputEvents;
private readonly IWorldInfoHub worldInfoHub;
private readonly IScenesCache scenesCache;
@@ -72,6 +74,7 @@ public McpServerPlugin(
DiagnosticsContainer diagnosticsContainer,
ExposedCameraData exposedCameraData,
IEntityCollidersGlobalCache entityCollidersGlobalCache,
+ GlobalInputEvents globalInputEvents,
ICoroutineRunner coroutineRunner,
Arch.Core.World globalWorld,
bool localSceneDevelopment)
@@ -91,6 +94,7 @@ public McpServerPlugin(
this.reloadSceneController = reloadSceneController;
this.exposedCameraData = exposedCameraData;
this.entityCollidersGlobalCache = entityCollidersGlobalCache;
+ this.globalInputEvents = globalInputEvents;
this.coroutineRunner = coroutineRunner;
this.globalWorld = globalWorld;
this.localSceneDevelopment = localSceneDevelopment;
@@ -113,6 +117,7 @@ public void InjectToWorld(ref ArchSystemsWorldBuilder builder,
{
McpInputOverrideSystem.InjectToWorld(ref builder, arguments.PlayerEntity);
McpPointerEventSystem.InjectToWorld(ref builder, scenesCache, entityCollidersGlobalCache, arguments.PlayerEntity);
+ McpInputActionSystem.InjectToWorld(ref builder, scenesCache, globalInputEvents, arguments.PlayerEntity);
screenshotTool = new ScreenshotTool(coroutineRunner, globalWorld, arguments.PlayerEntity);
@@ -133,6 +138,7 @@ public void InjectToWorld(ref ArchSystemsWorldBuilder builder,
.Add(new GetEntityDetailsTool(worldInfoHub))
.Add(new TriggerEmoteTool(globalWorldActions))
.Add(new ClickEntityTool(globalWorld, arguments.PlayerEntity))
+ .Add(new PressInputActionTool(globalWorld, arguments.PlayerEntity))
.Build();
server = new McpHttpServer(toolsRegistry, port);
diff --git a/Explorer/Assets/DCL/McpServer/Tests/McpInputActionSystemShould.cs b/Explorer/Assets/DCL/McpServer/Tests/McpInputActionSystemShould.cs
new file mode 100644
index 00000000000..925eb35af87
--- /dev/null
+++ b/Explorer/Assets/DCL/McpServer/Tests/McpInputActionSystemShould.cs
@@ -0,0 +1,234 @@
+using Arch.Core;
+using Cysharp.Threading.Tasks;
+using DCL.ECSComponents;
+using DCL.Interaction.PlayerOriginated;
+using DCL.Ipfs;
+using DCL.McpServer.Components;
+using DCL.McpServer.Systems;
+using DCL.Utilities;
+using ECS.SceneLifeCycle;
+using ECS.TestSuite;
+using NSubstitute;
+using NUnit.Framework;
+using SceneRunner.Scene;
+using System.Collections.Generic;
+using Utility.Multithreading;
+
+namespace DCL.McpServer.Tests
+{
+ public class McpInputActionSystemShould : UnitySystemTestBase
+ {
+ /// The three ways the current scene can be unusable, which the system rejects identically.
+ public enum SceneGuard
+ {
+ ABSENT,
+ NOT_CURRENT,
+ NOT_RUNNING,
+ }
+
+ private Entity playerEntity;
+ private GlobalInputEvents globalInputEvents = null!;
+ private ISceneStateProvider sceneStateProvider = null!;
+ private IReadonlyReactiveProperty currentScene = null!;
+ private uint tick;
+
+ [SetUp]
+ public void SetUp()
+ {
+ playerEntity = world.Create();
+ globalInputEvents = new GlobalInputEvents();
+
+ tick = 100u;
+ sceneStateProvider = Substitute.For();
+ sceneStateProvider.IsCurrent.Returns(true);
+ sceneStateProvider.State.Returns(new Atomic(SceneState.Running));
+ sceneStateProvider.TickNumber.Returns(_ => tick);
+
+ ISceneFacade sceneFacade = Substitute.For();
+ sceneFacade.SceneStateProvider.Returns(sceneStateProvider);
+ sceneFacade.SceneData.SceneEntityDefinition.Returns(new SceneEntityDefinition("scene-here", new SceneMetadata()));
+
+ currentScene = Substitute.For>();
+ currentScene.Value.Returns(sceneFacade);
+
+ IScenesCache scenesCache = Substitute.For();
+ scenesCache.CurrentScene.Returns(currentScene);
+
+ system = new McpInputActionSystem(world, scenesCache, globalInputEvents, playerEntity);
+ }
+
+ [TestCase(null, PointerEventType.PetDown)]
+ [TestCase(null, PointerEventType.PetUp)]
+ [TestCase("scene-here", PointerEventType.PetDown)]
+ public void PublishALoneEdgeAndCompleteImmediately(string? sceneId, PointerEventType eventType)
+ {
+ // Arrange
+ UniTaskCompletionSource completion = AddIntent(eventType, sceneId: sceneId);
+
+ // Act
+ system!.Update(0);
+
+ // Assert
+ AssertPublished(0, eventType);
+ McpInputActionResult result = ResultOf(completion);
+ Assert.That(result.Delivered, Is.True);
+ Assert.That(result.SceneId, Is.EqualTo("scene-here"));
+ Assert.That(result.ReleaseMissed, Is.False);
+ Assert.That(world.Has(playerEntity), Is.False);
+ }
+
+ [Test]
+ public void WithholdTheReleaseUntilBothTheHoldAndTheSceneTickHaveMovedOn()
+ {
+ // A press and its release sharing one scene tick collapse into an ambiguous button state in the SDK,
+ // which keys pointer results by that tick — so an elapsed hold alone must not let the release out.
+
+ // Arrange
+ UniTaskCompletionSource completion = AddIntent(PointerEventType.PetDown, holdSeconds: 60f);
+
+ // Act
+ system!.Update(0); // publish the press
+ system.Update(0); // stamp the tick the release is gated against
+ tick++;
+ system.Update(0); // the tick gate is open, the hold is not
+
+ // Assert
+ Assert.That(globalInputEvents.Entries.Count, Is.EqualTo(1));
+ Assert.That(completion.Task.Status, Is.EqualTo(UniTaskStatus.Pending));
+ Assert.That(world.Has(playerEntity), Is.True);
+
+ // Act: back the press time off so the hold is over, and put the scene back on the press tick.
+ world.Get(playerEntity).PressTime = UnityEngine.Time.time - 61f;
+ tick--;
+ system.Update(0);
+
+ // Assert
+ Assert.That(globalInputEvents.Entries.Count, Is.EqualTo(1), "the release must not share the press tick");
+ Assert.That(completion.Task.Status, Is.EqualTo(UniTaskStatus.Pending));
+
+ // Act
+ tick++;
+ system.Update(0);
+
+ // Assert
+ AssertPublished(1, PointerEventType.PetUp);
+ McpInputActionResult result = ResultOf(completion);
+ Assert.That(result.Delivered, Is.True);
+ Assert.That(result.ReleaseMissed, Is.False);
+ Assert.That(result.HeldSeconds, Is.GreaterThan(60f));
+ Assert.That(world.Has(playerEntity), Is.False);
+ }
+
+ [Test]
+ public void ReportAPressWhoseReleaseTheSceneGuardRejected()
+ {
+ // Arrange
+ UniTaskCompletionSource completion = AddIntent(PointerEventType.PetDown, holdSeconds: 0f);
+
+ system!.Update(0);
+ system.Update(0);
+
+ // Act
+ sceneStateProvider.IsCurrent.Returns(false);
+ tick++;
+ system.Update(0);
+
+ // Assert
+ Assert.That(globalInputEvents.Entries.Count, Is.EqualTo(1), "only the press reached the scene");
+ McpInputActionResult result = ResultOf(completion);
+ Assert.That(result.Delivered, Is.True);
+ Assert.That(result.ReleaseMissed, Is.True);
+ Assert.That(result.FailureReason, Does.Contain("no running current scene"));
+ }
+
+ [TestCase(SceneGuard.ABSENT)]
+ [TestCase(SceneGuard.NOT_CURRENT)]
+ [TestCase(SceneGuard.NOT_RUNNING)]
+ public void FailWhenThereIsNoRunningCurrentScene(SceneGuard guard)
+ {
+ // Arrange
+ switch (guard)
+ {
+ case SceneGuard.ABSENT:
+ currentScene.Value.Returns((ISceneFacade?)null);
+ break;
+ case SceneGuard.NOT_CURRENT:
+ sceneStateProvider.IsCurrent.Returns(false);
+ break;
+ case SceneGuard.NOT_RUNNING:
+ sceneStateProvider.State.Returns(new Atomic(SceneState.JavaScriptError));
+ break;
+ }
+
+ UniTaskCompletionSource completion = AddIntent(PointerEventType.PetDown);
+
+ // Act
+ system!.Update(0);
+
+ // Assert
+ Assert.That(globalInputEvents.Entries, Is.Empty);
+ McpInputActionResult result = ResultOf(completion);
+ Assert.That(result.Delivered, Is.False);
+ Assert.That(result.ReleaseMissed, Is.False);
+ Assert.That(result.FailureReason, Does.Contain("no running current scene"));
+ }
+
+ [Test]
+ public void FailWhenPinnedSceneIsNotCurrent()
+ {
+ // Arrange
+ UniTaskCompletionSource completion = AddIntent(PointerEventType.PetDown, sceneId: "scene-elsewhere");
+
+ // Act
+ system!.Update(0);
+
+ // Assert
+ Assert.That(globalInputEvents.Entries, Is.Empty);
+ Assert.That(ResultOf(completion).FailureReason, Does.Contain("pinned"));
+ }
+
+ [Test]
+ public void LeaveTheBufferAloneWhenNoRequestIsPending()
+ {
+ // The buffer belongs to the real input pipeline; an idle update of this system must not touch it.
+
+ // Arrange
+ globalInputEvents.Add(new IGlobalInputEvents.Entry(InputAction.IaJump, PointerEventType.PetDown));
+
+ // Act
+ system!.Update(0);
+
+ // Assert
+ Assert.That(globalInputEvents.Entries.Count, Is.EqualTo(1));
+ }
+
+ private UniTaskCompletionSource AddIntent(
+ PointerEventType eventType,
+ float? holdSeconds = null,
+ string? sceneId = null)
+ {
+ var completion = new UniTaskCompletionSource();
+
+ world.Add(playerEntity, new McpInputActionIntent(InputAction.IaAction5, sceneId, eventType, holdSeconds)
+ {
+ Completion = completion,
+ });
+
+ return completion;
+ }
+
+ private void AssertPublished(int index, PointerEventType eventType)
+ {
+ IReadOnlyList entries = globalInputEvents.Entries;
+ Assert.That(entries.Count, Is.GreaterThan(index));
+ Assert.That(entries[index].InputAction, Is.EqualTo(InputAction.IaAction5));
+ Assert.That(entries[index].PointerEventType, Is.EqualTo(eventType));
+ }
+
+ private static McpInputActionResult ResultOf(UniTaskCompletionSource completion)
+ {
+ Assert.That(completion.Task.Status, Is.EqualTo(UniTaskStatus.Succeeded));
+ return completion.Task.GetAwaiter().GetResult();
+ }
+ }
+}
diff --git a/Explorer/Assets/DCL/McpServer/Tests/McpInputActionSystemShould.cs.meta b/Explorer/Assets/DCL/McpServer/Tests/McpInputActionSystemShould.cs.meta
new file mode 100644
index 00000000000..a9f18f7317b
--- /dev/null
+++ b/Explorer/Assets/DCL/McpServer/Tests/McpInputActionSystemShould.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: dff0c49d035f427abfe51431b5c21f61
\ No newline at end of file
diff --git a/Explorer/Assets/DCL/McpServer/Tests/PressInputActionToolShould.cs b/Explorer/Assets/DCL/McpServer/Tests/PressInputActionToolShould.cs
new file mode 100644
index 00000000000..c6045a4cb2d
--- /dev/null
+++ b/Explorer/Assets/DCL/McpServer/Tests/PressInputActionToolShould.cs
@@ -0,0 +1,120 @@
+using Arch.Core;
+using Cysharp.Threading.Tasks;
+using DCL.ECSComponents;
+using DCL.McpServer.Components;
+using DCL.McpServer.Core;
+using DCL.McpServer.Tools;
+using Newtonsoft.Json.Linq;
+using NUnit.Framework;
+using System;
+using System.Collections.Generic;
+using System.Threading;
+
+namespace DCL.McpServer.Tests
+{
+ public class PressInputActionToolShould
+ {
+ private World world = null!;
+ private Entity playerEntity;
+ private PressInputActionTool tool = null!;
+ private CancellationTokenSource cts = null!;
+
+ [SetUp]
+ public void SetUp()
+ {
+ world = World.Create();
+ playerEntity = world.Create();
+ tool = new PressInputActionTool(world, playerEntity);
+ cts = new CancellationTokenSource();
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ // Accepted calls stay awaiting a system this suite does not run; cancelling unwinds them.
+ cts.Cancel();
+ cts.Dispose();
+ world.Dispose();
+ }
+
+ [Test]
+ public void OfferEveryInputActionOnTheWire()
+ {
+ // A scene may read any action, and the production key map binds all of them, so the tool narrows
+ // nothing — unlike click_entity, which passes a subset of the same enum.
+
+ // Arrange
+ var wireNames = new List();
+
+ foreach (JToken value in tool.InputSchema["properties"]!["action"]!["enum"]!)
+ wireNames.Add(value.Value()!);
+
+ // Assert
+ Assert.That(wireNames, Is.EqualTo(McpWireEnum.WIRE_NAMES));
+ }
+
+ [Test]
+ public void SpellEveryProtobufInputActionMemberOnTheWire()
+ {
+ // McpInputAction converts to InputAction by cast, so it has to stay a faithful renaming: same member
+ // count, and each member carrying the value of the protobuf member its own name spells. A member
+ // added or renumbered upstream fails here instead of quietly sending a scene the wrong action.
+
+ // Arrange
+ var wireMembers = (McpInputAction[])Enum.GetValues(typeof(McpInputAction));
+
+ // Assert
+ Assert.That(wireMembers.Length, Is.EqualTo(Enum.GetValues(typeof(InputAction)).Length));
+
+ foreach (McpInputAction wireMember in wireMembers)
+ {
+ // "ACTION_5" ↔ "IaAction5": the protobuf spelling of the same name.
+ string protobufName = wireMember.ToInputAction().ToString().ToUpperInvariant();
+ Assert.That(protobufName, Is.EqualTo($"IA{wireMember.ToString().Replace("_", string.Empty)}"),
+ $"{wireMember} maps to {wireMember.ToInputAction()}, which is a different action");
+ }
+ }
+
+ [TestCase("{}", "action is required")]
+ [TestCase("{'action':'ia_action_5'}", "action is required")]
+ [TestCase("{'action':'action_5','eventType':'tap'}", "eventType must be one of")]
+ public void RefuseArgumentsItCannotActOn(string arguments, string expectedFragment)
+ {
+ // Act: a refusal is answered before the tool's first await, so the task is already done.
+ UniTask call = tool.ExecuteAsync(JObject.Parse(arguments), cts.Token);
+
+ // Assert
+ Assert.That(call.Status, Is.EqualTo(UniTaskStatus.Succeeded), "the tool was expected to refuse");
+ McpToolResult result = call.GetAwaiter().GetResult();
+ Assert.That(result.Payload["isError"]!.Value(), Is.True);
+ Assert.That(result.Payload["content"]![0]!["text"]!.Value(), Does.Contain(expectedFragment));
+ Assert.That(world.Has(playerEntity), Is.False);
+ }
+
+ [TestCase("{'action':'action_5','eventType':'down'}", InputAction.IaAction5, PointerEventType.PetDown, null)]
+ [TestCase("{'action':'primary','eventType':'up'}", InputAction.IaPrimary, PointerEventType.PetUp, null)]
+ [TestCase("{'action':'action_5','holdSec':900}", InputAction.IaAction5, PointerEventType.PetDown, 30f)]
+ public void InstallTheRequestedLegWithItsHoldClamped(string arguments, InputAction action, PointerEventType eventType, float? holdSeconds)
+ {
+ // Act: an accepted call is left awaiting the system this suite does not run, so the request it
+ // installed on the player entity is what the test reads.
+ tool.ExecuteAsync(JObject.Parse(arguments), cts.Token).Forget();
+
+ // Assert
+ McpInputActionIntent intent = world.Get(playerEntity);
+ Assert.That(intent.Action, Is.EqualTo(action));
+ Assert.That(intent.EventType, Is.EqualTo(eventType));
+ Assert.That(intent.HoldSeconds, Is.EqualTo(holdSeconds));
+ }
+
+ [Test]
+ public void PinTheRequestToASceneWhenAsked()
+ {
+ // Act
+ tool.ExecuteAsync(new JObject { ["action"] = "action_5", ["sceneId"] = "scene-here" }, cts.Token).Forget();
+
+ // Assert
+ Assert.That(world.Get(playerEntity).SceneId, Is.EqualTo("scene-here"));
+ }
+ }
+}
diff --git a/Explorer/Assets/DCL/McpServer/Tests/PressInputActionToolShould.cs.meta b/Explorer/Assets/DCL/McpServer/Tests/PressInputActionToolShould.cs.meta
new file mode 100644
index 00000000000..3ed7941c7ec
--- /dev/null
+++ b/Explorer/Assets/DCL/McpServer/Tests/PressInputActionToolShould.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 8a5313d754354c958dacca15bd9556cc
\ No newline at end of file
diff --git a/Explorer/Assets/DCL/McpServer/Tools/ClickEntityTool.cs b/Explorer/Assets/DCL/McpServer/Tools/ClickEntityTool.cs
index f09ed29b1bb..bb79eff1575 100644
--- a/Explorer/Assets/DCL/McpServer/Tools/ClickEntityTool.cs
+++ b/Explorer/Assets/DCL/McpServer/Tools/ClickEntityTool.cs
@@ -20,14 +20,6 @@ namespace DCL.McpServer.Tools
///
public class ClickEntityTool : McpTool
{
- /// Wire-facing subset of : only the three pointer buttons make sense for a click.
- private enum PointerButton : byte
- {
- POINTER,
- PRIMARY,
- SECONDARY,
- }
-
/// Wire-facing gesture kinds: a full click, or a single press/release leg.
private enum ClickKind : byte
{
@@ -44,6 +36,22 @@ private enum ClickKind : byte
private const float MIN_TIMEOUT_SEC = 0.5f;
private const float MAX_TIMEOUT_SEC = 15f;
+ ///
+ /// The actions a click can carry: the three pointer buttons plus the four action buttons, because a
+ /// PointerEvents entry may name any of them as its button. The movement actions and IA_ANY are left
+ /// out — they are not buttons a cursor clicks with; press_input_action sends those with no target.
+ ///
+ private static readonly McpInputAction[] ALLOWED_BUTTONS =
+ {
+ McpInputAction.POINTER,
+ McpInputAction.PRIMARY,
+ McpInputAction.SECONDARY,
+ McpInputAction.ACTION_3,
+ McpInputAction.ACTION_4,
+ McpInputAction.ACTION_5,
+ McpInputAction.ACTION_6,
+ };
+
private readonly World world;
private readonly Entity playerEntity;
@@ -53,7 +61,8 @@ private enum ClickKind : byte
"Press and release a pointer button on a scene entity so its PointerEvents fire exactly like a real click. "
+ "The click runs through the real reticle pipeline: occluders and the entity's maxDistance apply, and a miss "
+ "returns hit:false with the blocking entity. Ids come from list_scene_entities. For entities whose collider "
- + "sits away from their pivot (e.g. GLTF meshes), pass an explicit x/y/z world point to aim at.";
+ + "sits away from their pivot (e.g. GLTF meshes), pass an explicit x/y/z world point to aim at. Every path "
+ + "here needs a collider to land on: to drive a scene that polls input globally, use press_input_action.";
protected override McpJsonSchema DescribeInput(McpJsonSchema schema) =>
schema.Integer("entityId", "Target entity id in the current scene world (from list_scene_entities). Omit only when x/y/z are given, then the ray decides the target.")
@@ -61,7 +70,7 @@ protected override McpJsonSchema DescribeInput(McpJsonSchema schema) =>
.Number("y")
.Number("z")
.String("sceneId", "Pin the click to this scene (id from get_scene_state): it fails instead of landing in another scene if the player moved.")
- .Enum("button", "Which input action to press. Default pointer (left click / IA_POINTER).")
+ .Enum("button", "Which input action to press on the entity. Default pointer (left click / IA_POINTER).", ALLOWED_BUTTONS)
.Enum("eventType", "click = down, then up on the next scene tick. Default click.")
.Number("timeoutSec", "Seconds to wait for delivery. Default 3, max 15.");
@@ -84,15 +93,10 @@ public override async UniTask ExecuteAsync(JObject arguments, Can
if (!hasEntityId && !hasAimPoint)
return McpToolResult.Error("Provide entityId, or a full x/y/z world aim point, or both.");
- if (!arguments.TryGetEnum("button", PointerButton.POINTER, out PointerButton pointerButton))
- return McpToolResult.Error("button must be one of: pointer, primary, secondary.");
+ if (!arguments.TryGetEnum("button", McpInputAction.POINTER, out McpInputAction wireButton, ALLOWED_BUTTONS))
+ return McpToolResult.Error($"button must be one of: {string.Join(", ", McpWireEnum.WireNamesOf(ALLOWED_BUTTONS))}.");
- InputAction button = pointerButton switch
- {
- PointerButton.PRIMARY => InputAction.IaPrimary,
- PointerButton.SECONDARY => InputAction.IaSecondary,
- _ => InputAction.IaPointer,
- };
+ InputAction button = wireButton.ToInputAction();
if (!arguments.TryGetEnum("eventType", ClickKind.CLICK, out ClickKind kind))
return McpToolResult.Error("eventType must be one of: click, down, up.");
diff --git a/Explorer/Assets/DCL/McpServer/Tools/PressInputActionTool.cs b/Explorer/Assets/DCL/McpServer/Tools/PressInputActionTool.cs
new file mode 100644
index 00000000000..f216356d5aa
--- /dev/null
+++ b/Explorer/Assets/DCL/McpServer/Tools/PressInputActionTool.cs
@@ -0,0 +1,153 @@
+using Arch.Core;
+using Cysharp.Threading.Tasks;
+using DCL.ECSComponents;
+using DCL.McpServer.Components;
+using DCL.McpServer.Core;
+using DCL.McpServer.Utils;
+using JetBrains.Annotations;
+using Newtonsoft.Json.Linq;
+using System;
+using System.Threading;
+using UnityEngine;
+
+namespace DCL.McpServer.Tools
+{
+ ///
+ /// Sends an edge to the current scene with no entity and no aim involved, by
+ /// way of an that McpInputActionSystem publishes: it lands on the
+ /// scene root entity the way a key press does, which is what a scene polling inputSystem.isTriggered /
+ /// isPressed every frame reads. This is the counterpart of click_entity, whose every path requires a
+ /// collider the reticle can qualify — so a game that never registers a PointerEvents component is
+ /// unreachable through it. A press owns its release: the system emits the PetUp even if this call is
+ /// cancelled, so a dropped connection cannot leave the scene with a button stuck down.
+ ///
+ public class PressInputActionTool : McpTool
+ {
+ /// Wire-facing gesture kinds: a press that releases itself, or a single down/up leg.
+ private enum PressKind : byte
+ {
+ /// Down, then up once holdSec has elapsed and the scene has advanced a tick.
+ PRESS,
+
+ /// Down-only leg; parsed from the wire and exposed via the schema through reflection over this enum.
+ [UsedImplicitly]
+ DOWN,
+ UP,
+ }
+
+ private const float DEFAULT_HOLD_SEC = 0.2f;
+ private const float MIN_HOLD_SEC = 0.1f;
+ private const float MAX_HOLD_SEC = 30f;
+
+ private const float DEFAULT_TIMEOUT_SEC = 3f;
+ private const float MIN_TIMEOUT_SEC = 0.5f;
+ private const float MAX_TIMEOUT_SEC = 15f;
+
+ private static readonly string ACTION_NAMES = string.Join(", ", McpWireEnum.WIRE_NAMES);
+ private static readonly string KIND_NAMES = string.Join(", ", McpWireEnum.WIRE_NAMES);
+
+ private readonly World world;
+ private readonly Entity playerEntity;
+
+ public override string Name => "press_input_action";
+
+ public override string Description =>
+ "Send an SDK input action to the current scene without pointing at anything, so scenes that poll input "
+ + "globally (inputSystem.isTriggered / isPressed, with no entity argument) react. Use this for game "
+ + "controls; use click_entity when the scene registered the action on a specific entity. press holds "
+ + "the button for holdSec and releases it on a later scene tick, so a scene reading isPressed observes "
+ + "a real hold. A lone down leaves the scene believing the button is still held until a matching up.";
+
+ protected override McpJsonSchema DescribeInput(McpJsonSchema schema) =>
+ schema.Enum("action", "Which input action to send, e.g. primary (IA_PRIMARY) or action_5 (IA_ACTION_5).", isRequired: true)
+ .Enum("eventType", "press = down, then up after holdSec. Default press.")
+ .Number("holdSec", "How long a press keeps the button down. Default 0.2, min 0.1, max 30.")
+ .String("sceneId", "Pin the input to this scene (id from get_scene_state): it fails instead of landing in another scene if the player moved.")
+ .Number("timeoutSec", "Seconds to wait for the edge to be published. Default 3, max 15.");
+
+ public override McpToolAnnotations Annotations => McpToolAnnotations.Mutating(destructive: false, idempotent: false);
+
+ public PressInputActionTool(World world, Entity playerEntity)
+ {
+ this.world = world;
+ this.playerEntity = playerEntity;
+ }
+
+ public override async UniTask ExecuteAsync(JObject arguments, CancellationToken ct)
+ {
+ if (!arguments.TryGetEnum("action", out McpInputAction action))
+ return McpToolResult.Error($"action is required and must be one of: {ACTION_NAMES}.");
+
+ if (!arguments.TryGetEnum("eventType", PressKind.PRESS, out PressKind kind))
+ return McpToolResult.Error($"eventType must be one of: {KIND_NAMES}.");
+
+ float holdSec = Mathf.Clamp(arguments.GetFloat("holdSec", DEFAULT_HOLD_SEC), MIN_HOLD_SEC, MAX_HOLD_SEC);
+ float timeoutSec = Mathf.Clamp(arguments.GetFloat("timeoutSec", DEFAULT_TIMEOUT_SEC), MIN_TIMEOUT_SEC, MAX_TIMEOUT_SEC);
+ string? sceneId = arguments["sceneId"]?.Type == JTokenType.String ? arguments["sceneId"]!.Value() : null;
+
+ var intent = new McpInputActionIntent(
+ action.ToInputAction(),
+ sceneId,
+ kind == PressKind.UP ? PointerEventType.PetUp : PointerEventType.PetDown,
+ kind == PressKind.PRESS ? holdSec : null);
+
+ // A press spends most of its budget holding the button down, so the timeout that guards a stuck
+ // simulation has to clear the hold itself before it can mean anything.
+ float budgetSec = kind == PressKind.PRESS ? holdSec + timeoutSec : timeoutSec;
+
+ McpInputActionResult result;
+
+ try
+ {
+ result = await McpEcsRequest.SendAsync(world, playerEntity, intent, PreemptedResult(world, playerEntity))
+ .AttachExternalCancellation(ct)
+ .Timeout(TimeSpan.FromSeconds(budgetSec));
+ }
+ catch (TimeoutException)
+ {
+ await McpEcsRequest.AbandonAsync(world, playerEntity);
+ return McpToolResult.Error($"press_input_action did not complete within {budgetSec}s (is the simulation paused?).");
+ }
+
+ var json = new JObject
+ {
+ ["delivered"] = result.Delivered,
+ ["action"] = McpWireEnum.ToWire(action),
+ ["eventType"] = McpWireEnum.ToWire(kind),
+ };
+
+ if (result.SceneId != null)
+ json["sceneId"] = result.SceneId;
+
+ if (result.FailureReason != null)
+ json["reason"] = result.FailureReason;
+
+ if (kind == PressKind.PRESS && result.Delivered && !result.ReleaseMissed)
+ json["heldSec"] = Math.Round(result.HeldSeconds, 2);
+
+ if (result.ReleaseMissed)
+ json["releaseMissed"] = true;
+
+ return McpToolResult.Json(json);
+ }
+
+ ///
+ /// What the call being preempted reports. Only one input action is in flight at a time, so this call
+ /// drops whatever the previous one had going; when that was a press already down, the scene keeps
+ /// seeing the button held until the same action is sent again with eventType up, which is what
+ /// releaseMissed tells the preempted caller. It has to be read before SendAsync overwrites it.
+ ///
+ private static McpInputActionResult PreemptedResult(World world, Entity playerEntity)
+ {
+ bool heldPressDropped = world.TryGet(playerEntity, out McpInputActionIntent pending)
+ && pending.PressTime.HasValue;
+
+ return new McpInputActionResult
+ {
+ Delivered = heldPressDropped,
+ ReleaseMissed = heldPressDropped,
+ FailureReason = "preempted by a newer press_input_action call",
+ };
+ }
+ }
+}
diff --git a/Explorer/Assets/DCL/McpServer/Tools/PressInputActionTool.cs.meta b/Explorer/Assets/DCL/McpServer/Tools/PressInputActionTool.cs.meta
new file mode 100644
index 00000000000..f71ce28cccb
--- /dev/null
+++ b/Explorer/Assets/DCL/McpServer/Tools/PressInputActionTool.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 5db2dc3424014417abdb91e5b48511a7
\ No newline at end of file
diff --git a/docs/mcp-automation.md b/docs/mcp-automation.md
index 829379270bb..2a463efa8df 100644
--- a/docs/mcp-automation.md
+++ b/docs/mcp-automation.md
@@ -1,6 +1,6 @@
# MCP Automation Server
-The Explorer can host an embedded [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server so coding agents (e.g. Claude Code) can **see** the running client (screenshots, player/scene state, scene console logs) and **control** it (teleport, move, walk, look, chat commands, scene reload) — closing the edit → reload → verify loop for SDK7 scene development without a human in the middle.
+The Explorer can host an embedded [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server so coding agents (e.g. Claude Code) can **see** the running client (screenshots, player/scene state, scene console logs) and **control** it (teleport, move, walk, look, click, press input actions, chat commands, scene reload) — closing the edit → reload → verify loop for SDK7 scene development without a human in the middle.
The server is compiled into all builds but stays dormant unless explicitly enabled at launch.
@@ -80,7 +80,8 @@ The tables below are a human-readable overview. The authoritative argument contr
| `send_chat` | `message` | Sends to Nearby chat; `/commands` run through the chat command pipeline |
| `reload_scene` | `timeoutSec?` | Reloads the current scene (motion + skybox frozen during reload) |
| `trigger_emote` | `urn` or `stop: true`, `loop?` | Plays or stops an avatar emote |
-| `click_entity` | `entityId` and/or `x`,`y`,`z` aim point, `button?`, `eventType?`, `timeoutSec?` | Presses a pointer button on a scene entity exactly like a real click: a camera-origin raycast validates the aim (occluders and the entity's `maxDistance` apply), then the entity's pointer-event intent is filled so the scene receives an identical `PBPointerEventsResult`. `click` sends down + up on consecutive scene ticks. Returns `hit`, hover text, hit point/distance, or the blocking entity |
+| `click_entity` | `entityId` and/or `x`,`y`,`z` aim point, `button?`, `eventType?`, `timeoutSec?` | Presses a pointer button on a scene entity exactly like a real click: a camera-origin raycast validates the aim (occluders and the entity's `maxDistance` apply), then the entity's pointer-event intent is filled so the scene receives an identical `PBPointerEventsResult`. `click` sends down + up on consecutive scene ticks. `button` accepts the three pointer buttons plus `action_3`…`action_6`. Returns `hit`, hover text, hit point/distance, or the blocking entity |
+| `press_input_action` | `action`, `eventType?`, `holdSec?`, `sceneId?`, `timeoutSec?` | Sends an `InputAction` edge with **no target** — the shape a scene reads with `inputSystem.isTriggered` / `isPressed` and no entity argument, which no other tool can reach. `press` (default) holds the button for `holdSec` and releases it on a later scene tick; `down`/`up` send a single leg. Returns `delivered`, the scene it landed in and the measured `heldSec` |
## Structured output
@@ -114,15 +115,17 @@ A user-invokable Claude Code skill wrapping this loop lives at `.claude/skills/m
- **Verbose logs** — enabling the server registers a scene-console log handler, which turns on unconditional verbose logging for the session (same behavior as `--scene-console`).
- **Scene entity dumps** — `list_scene_entities`/`get_entity_details` read the scene world without acquiring its sync lock (same as the existing `WorldInfoTool` debug tooling); treat results as a diagnostic snapshot.
- **`click_entity` returns `hit:false` with `blockedBy*`** — another collider sits on the camera→target line; `move_to`/`look_at` to a clear vantage and retry. If the reason is "out of range", close within the entity's `maxDistance` (default 10 m) first. Entities whose collider sits away from the pivot (GLTF meshes) may need an explicit `x/y/z` aim point.
+- **A scene ignores `click_entity` but is clearly input-driven** — it reads input globally instead of registering `PointerEvents` on an entity. Use `press_input_action`, which needs no target at all.
+- **`press_input_action` reports `releaseMissed`** — the press reached the scene but its release did not, so the scene still believes the button is held: send the same `action` again with `eventType: "up"`. It happens when the scene stopped being current or stopped running mid-hold, or when a second call preempted the held press (only one input action is in flight at a time).
## Implementation map
- `Explorer/Assets/DCL/McpServer/` — feature root, its own `DCL.McpServer` assembly. Two folders are folded into other assemblies via `.asmref` so they can reach code that assembly doesn't reference:
- - `Core/` — protocol, transport and tool contract: `McpHttpServer` (`HttpListener` server + Origin validation), `McpJsonRpcDispatcher` (JSON-RPC 2.0 routing; `PROTOCOL_VERSION` `2025-06-18`), `McpTool` (abstract tool base), `McpToolsRegistry`, `McpToolResult`, `McpToolAnnotations` (behaviour hints), `McpJsonSchema` (typed schema builder).
- - `Tools/` — one class per tool (16).
- - `Components/` — ECS components for the input-driving tools: `McpMovementOverride`, `McpPointerEventIntent`.
- - `Systems/` — **folded into `DCL.Plugins`** via `.asmref`: `McpServerPlugin` (builds the registry and hosts the server in `InjectToWorld`), `McpInputOverrideSystem` (held movement), `McpPointerEventSystem` (synthetic pointer press/release delivery; `ClickEntityTool` composes a click from two intents).
+ - `Core/` — protocol, transport and tool contract: `McpHttpServer` (`HttpListener` server + Origin validation), `McpJsonRpcDispatcher` (JSON-RPC 2.0 routing; `PROTOCOL_VERSION` `2025-06-18`), `McpTool` (abstract tool base), `McpToolsRegistry`, `McpToolResult`, `McpToolAnnotations` (behaviour hints), `McpJsonSchema` (typed schema builder), `McpInputAction` (wire spelling of the protobuf `InputAction`, shared by the two input tools).
+ - `Tools/` — one class per tool (17).
+ - `Components/` — ECS components for the input-driving tools: `McpMovementOverride`, `McpPointerEventIntent`, `McpInputActionIntent`.
+ - `Systems/` — **folded into `DCL.Plugins`** via `.asmref`: `McpServerPlugin` (builds the registry and hosts the server in `InjectToWorld`), `McpInputOverrideSystem` (held movement), `McpPointerEventSystem` (synthetic pointer press/release delivery; `ClickEntityTool` composes a click from two intents), `McpInputActionSystem` (targetless input actions, published into the same `GlobalInputEvents` buffer the key bindings feed so the current scene's `WritePointerEventResultsSystem` writes them to its root entity).
- `Utils/` — `SceneLogBuffer`, `JObjectExtensions`.
- - `Tests/` — EditMode tests **folded into `DCL.EditMode.Tests`** via `.asmref`: dispatcher / registry / result routing and the pointer-click system.
+ - `Tests/` — EditMode tests **folded into `DCL.EditMode.Tests`** via `.asmref`: dispatcher / registry / result routing, the pointer-click system and the input-action system.
- Gating: `FeatureId.MCP_SERVER` in `FeaturesRegistry` (resolved as `appArgs.HasFlag(MCP) || appArgs.HasFlag(MCP_PORT)`); `DynamicWorldContainer.CreateAsync` reads `FeaturesRegistry.Instance.IsEnabled(FeatureId.MCP_SERVER)` and adds `McpServerPlugin`.
- Flags: `AppArgsFlags.MCP` / `AppArgsFlags.MCP_PORT`; log category: `ReportCategory.MCP`.