Skip to content

Commit 5ff1b28

Browse files
committed
mcp: extract the shared request/completion choreography into McpRequest
The preempt → install → complete/abandon ritual duplicated between ClickEntityTool/McpPointerEventSystem and WalkTool/McpInputOverrideSystem now lives in one place: IMcpRequest<TResult> marks a request component and the McpRequest helper owns SendAsync (preempting a pending request), CompleteAndRemove (copy out, structural removal, then resolve the awaiter) and AbandonAsync (tool-side timeout cleanup). McpMovementOverride joins the contract via an AsyncUnit completion. McpPointerEventIntent is now a genuinely immutable request: readonly fields set through the constructor, AimPoint + HasExplicitAimPoint merged into a nullable Vector3, and the Deadline field is gone — the system-side deadline duplicated the tool-side .Timeout(), so the tool watchdog is now the single timeout. As a result click_entity times out after exactly timeoutSec (previously timeoutSec + 2s grace) and a timeout always surfaces as an MCP error instead of sometimes an in-band hit:false. Tests: McpRequestShould covers install, preemption, complete-and-remove and a missing completion; the deadline test is removed with the mechanism.
1 parent 20fdd39 commit 5ff1b28

13 files changed

Lines changed: 242 additions & 156 deletions

.claude/agents/mcp-server-engineer.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ Everything lives in `Explorer/Assets/DCL/McpServer/`, its own `DCL.McpServer` as
3232
| Plugin | `Systems/McpServerPlugin.cs` | Builds the tool registry in `InjectToWorld` (needs `GlobalPluginArguments.PlayerEntity/SkyboxEntity`), starts/disposes the server, wires the scene-log tap (`DiagnosticsContainer.AddDebugConsoleHandler`) |
3333
| Core (transport + protocol + contract) | `Core/McpHttpServer.cs`, `McpJsonRpcDispatcher.cs`, `McpTool.cs`, `McpToolsRegistry.cs`, `McpToolResult.cs`, `McpToolAnnotations.cs`, `McpJsonSchema.cs`, `McpWireEnum.cs` | `HttpListener` on `http://127.0.0.1:{port}/unity-explorer-mcp` (the URL template is single-sourced in `IDecentralandUrlsSource.LOCAL_MCP_ENDPOINT_URL`); POST → dispatch, GET → 405, Origin allowlist, 1 MB body cap; JSON-RPC 2.0 over Streamable HTTP (spec 2025-06-18), tools-only capability |
3434
| Tools | `Tools/*.cs` (one class per tool, 16) | The agent-facing surface |
35-
| Components | `Components/``McpMovementOverride`, `McpPointerClickIntent` | ECS intents for the input-driving tools |
36-
| Systems | `Systems/McpInputOverrideSystem.cs` (held movement), `Systems/McpPointerClickSystem.cs` (synthetic entity clicks) | Per-frame/pipeline-integrated drivers |
35+
| Components | `Components/``McpMovementOverride`, `McpPointerEventIntent` | ECS intents for the input-driving tools |
36+
| Systems | `Systems/McpInputOverrideSystem.cs` (held movement), `Systems/McpPointerEventSystem.cs` (synthetic pointer press/release delivery; `ClickEntityTool` composes a click from two intents) | Per-frame/pipeline-integrated drivers |
3737
| Utils | `Utils/SceneLogBuffer.cs`, `JObjectExtensions.cs` | Log tap buffer, args parsing |
3838

3939
Registration: `DynamicWorldContainer.CreateAsync`, gated on `FeaturesRegistry` `FeatureId.MCP_SERVER` = `appArgs.HasFlag(MCP) || appArgs.HasFlag(MCP_PORT)` — so `--mcp-port` alone implies `--mcp` (presence check; an invalid port value still enables the server and falls back to 8123). Flags accepted from CLI **or** deep link by user decision — do not add CLI-only enforcement without being asked. Log category: `ReportCategory.MCP`.
@@ -92,6 +92,6 @@ Forbidden: `git commit`, `git push`, `git merge`, `git rebase`
9292

9393
## Roadmap context
9494

95-
Milestone 2 (pointer clicks) SHIPPED 2026-07-05 as `click_entity` — implemented via **semantic injection**, not the originally-scoped synthetic `InputSystem` device: `McpPointerClickSystem` raycasts camera→target, mirrors the distance gate, and fills the entity's `PBPointerEvents.AppendPointerEventResultsIntent` so the unmodified `WritePointerEventResultsSystem` emits a byte-identical `PBPointerEventsResult`. Zero production interaction code changed; approach recorded in `~/.claude/plans/wondrous-forging-fox.md`.
95+
Milestone 2 (pointer clicks) SHIPPED 2026-07-05 as `click_entity` — implemented via **semantic injection**, not the originally-scoped synthetic `InputSystem` device: `McpPointerEventSystem` (née McpPointerClickSystem) raycasts camera→target, mirrors the distance gate, and fills the entity's `PBPointerEvents.AppendPointerEventResultsIntent` so the unmodified `WritePointerEventResultsSystem` emits a byte-identical `PBPointerEventsResult`. Zero production interaction code changed; approach recorded in `~/.claude/plans/wondrous-forging-fox.md`.
9696

9797
Current "Wanted tools" head: **recover_scene** — force-recreate a scene that dropped out of `ScenesCache` (`get_scene_state``scene: null`, the LSD hard-wedge from rapid saves where every existing reload path needs the cached facade). Implementation lead is in the skill's Wanted tools entry.

Explorer/Assets/DCL/McpServer/Components/McpMovementOverride.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using Cysharp.Threading.Tasks;
22
using DCL.CharacterMotion.Components;
3+
using DCL.McpServer.Core;
34
using UnityEngine;
45

56
namespace DCL.McpServer.Components
@@ -8,7 +9,7 @@ namespace DCL.McpServer.Components
89
/// Held movement input requested by the MCP walk tool. While present on the player entity,
910
/// <see cref="McpInputOverrideSystem" /> re-asserts it into <see cref="MovementInputComponent" /> every frame.
1011
/// </summary>
11-
public struct McpMovementOverride
12+
public struct McpEcsMovementOverride : IMcpEcsRequest<AsyncUnit>
1213
{
1314
/// <summary>
1415
/// Normalized camera-relative axes (x = strafe, y = forward).
@@ -30,6 +31,6 @@ public struct McpMovementOverride
3031
/// <summary>
3132
/// Completed by the system when the hold expires or is preempted by a newer request.
3233
/// </summary>
33-
public UniTaskCompletionSource? Completion;
34+
public UniTaskCompletionSource<AsyncUnit>? Completion { get; set; }
3435
}
3536
}

Explorer/Assets/DCL/McpServer/Components/McpPointerEventIntent.cs

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using Cysharp.Threading.Tasks;
22
using DCL.ECSComponents;
3+
using DCL.McpServer.Core;
34
using UnityEngine;
45
using RaycastHit = UnityEngine.RaycastHit;
56

@@ -8,31 +9,42 @@ namespace DCL.McpServer.Components
89
/// <summary>
910
/// Present on the player entity while a single agent-requested pointer event awaits delivery.
1011
/// McpPointerEventSystem validates the aim with a physics raycast, appends the event to the target's
11-
/// <see cref="PBPointerEvents.AppendPointerEventResultsIntent" /> and removes the component. The intent
12-
/// is written once and never mutated; a full click is composed by the click_entity tool from two intents —
13-
/// a press, then a release carrying the press <see cref="Press" /> handoff.
12+
/// <see cref="PBPointerEvents.AppendPointerEventResultsIntent" /> and removes the component. The request
13+
/// is immutable; a full click is composed by the click_entity tool from two intents — a press, then a
14+
/// release carrying the press <see cref="Press" /> handoff. A request the simulation never picks up is
15+
/// removed by the tool-side timeout.
1416
/// </summary>
15-
public struct McpPointerEventIntent
17+
public struct McpEcsPointerEventIntent : IMcpEcsRequest<McpPointerClickResult>
1618
{
1719
/// <summary>Arch entity id in the current scene world; -1 when aiming at an explicit world point.</summary>
18-
public int TargetEntityId;
19-
public Vector3 AimPoint;
20-
public bool HasExplicitAimPoint;
21-
public InputAction Button;
20+
public readonly int TargetEntityId;
21+
22+
/// <summary>Explicit world-space aim point; when null the aim is the target's collider center.</summary>
23+
public readonly Vector3? AimPoint;
24+
25+
public readonly InputAction Button;
2226

2327
/// <summary>PetDown or PetUp.</summary>
24-
public PointerEventType EventType;
28+
public readonly PointerEventType EventType;
2529

2630
/// <summary>
2731
/// Set on the release leg of a click: the press this release must stay ordered after. Delivery waits
2832
/// until the scene has advanced past the press tick, is bound to the world that received the press,
2933
/// and falls back to the press-frame hit when the fresh ray no longer reaches the target.
3034
/// </summary>
31-
public McpPressHandoff? Press;
35+
public readonly McpPressHandoff? Press;
36+
37+
public UniTaskCompletionSource<McpPointerClickResult>? Completion { get; set; }
3238

33-
/// <summary>Time.time after which delivery is abandoned.</summary>
34-
public float Deadline;
35-
public UniTaskCompletionSource<McpPointerClickResult>? Completion;
39+
public McpEcsPointerEventIntent(int targetEntityId, Vector3? aimPoint, InputAction button, PointerEventType eventType, McpPressHandoff? press = null)
40+
{
41+
TargetEntityId = targetEntityId;
42+
AimPoint = aimPoint;
43+
Button = button;
44+
EventType = eventType;
45+
Press = press;
46+
Completion = null;
47+
}
3648
}
3749

3850
/// <summary>
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
using Arch.Core;
2+
using Cysharp.Threading.Tasks;
3+
using Utility.Arch;
4+
5+
namespace DCL.McpServer.Core
6+
{
7+
/// <summary>
8+
/// A request component written onto an entity by an MCP tool and fulfilled by a consuming system.
9+
/// The shared install/complete/abandon choreography lives in <see cref="McpRequest" />.
10+
/// </summary>
11+
public interface IMcpEcsRequest<TResult>
12+
{
13+
/// <summary>
14+
/// Completed exactly once: with the outcome by the fulfilling system, or with a preemption result
15+
/// when a newer request replaces this one before it is fulfilled.
16+
/// </summary>
17+
UniTaskCompletionSource<TResult>? Completion { get; set; }
18+
}
19+
20+
/// <summary>
21+
/// The request/response choreography shared by MCP intent components: a tool installs the request
22+
/// (preempting a pending one), the consuming system completes and removes it, and the tool-side timeout
23+
/// abandons a request the simulation never picked up.
24+
/// </summary>
25+
public static class McpRequest
26+
{
27+
/// <summary>
28+
/// Installs the request on the entity and returns the task its consuming system will complete.
29+
/// A pending request of the same type is preempted: its awaiter is released with
30+
/// <paramref name="preemptedResult" /> before the component is replaced. Main thread only.
31+
/// </summary>
32+
public static UniTask<TResult> SendAsync<TIntent, TResult>(World world, Entity entity, TIntent request, TResult preemptedResult)
33+
where TIntent : struct, IMcpEcsRequest<TResult>
34+
{
35+
if (world.TryGet(entity, out TIntent existing))
36+
existing.Completion?.TrySetResult(preemptedResult);
37+
38+
var completion = new UniTaskCompletionSource<TResult>();
39+
request.Completion = completion;
40+
world.AddOrSet(entity, request);
41+
42+
return completion.Task;
43+
}
44+
45+
/// <summary>
46+
/// Removes the request component, then releases its awaiter with <paramref name="result" />.
47+
/// Takes the request by copy and removes before completing, so the awaiter's continuation observes
48+
/// the entity without the component; the caller must be done with any refs into the entity, as the
49+
/// removal is a structural change.
50+
/// </summary>
51+
public static void CompleteAndRemove<TIntent, TResult>(World world, Entity entity, TIntent request, TResult result)
52+
where TIntent : struct, IMcpEcsRequest<TResult>
53+
{
54+
UniTaskCompletionSource<TResult>? completion = request.Completion;
55+
world.Remove<TIntent>(entity);
56+
completion?.TrySetResult(result);
57+
}
58+
59+
/// <summary>Drops a request the simulation never completed (tool-side timeout). Safe to call from any thread.</summary>
60+
public static async UniTask AbandonAsync<TIntent>(World world, Entity entity) where TIntent : struct
61+
{
62+
await UniTask.SwitchToMainThread();
63+
64+
if (world.Has<TIntent>(entity))
65+
world.Remove<TIntent>(entity);
66+
}
67+
}
68+
}

Explorer/Assets/DCL/McpServer/Core/McpRequest.cs.meta

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Explorer/Assets/DCL/McpServer/Systems/McpInputOverrideSystem.cs

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@
77
using DCL.Diagnostics;
88
using DCL.Input;
99
using DCL.McpServer.Components;
10+
using DCL.McpServer.Core;
1011
using ECS.Abstract;
1112
using UnityEngine;
1213

1314
namespace DCL.McpServer.Systems
1415
{
1516
/// <summary>
16-
/// While an <see cref="McpMovementOverride" /> is present on the player entity, re-asserts its axes into
17+
/// While an <see cref="McpEcsMovementOverride" /> is present on the player entity, re-asserts its axes into
1718
/// <see cref="MovementInputComponent" /> after the real-input systems have written it, so an agent-requested
1819
/// walk survives the per-frame overwrite performed by <see cref="UpdateInputMovementSystem" />.
1920
/// </summary>
@@ -40,24 +41,24 @@ public override void Initialize()
4041

4142
protected override void Update(float t)
4243
{
43-
ref McpMovementOverride movementOverride = ref World.TryGetRef<McpMovementOverride>(playerEntity, out bool overrideExists);
44+
ref McpEcsMovementOverride ecsMovementOverride = ref World.TryGetRef<McpEcsMovementOverride>(playerEntity, out bool overrideExists);
4445

4546
if (!overrideExists)
4647
return;
4748

4849
ref MovementInputComponent movement = ref World.TryGetRef<MovementInputComponent>(playerEntity, out bool hasMovement);
4950

50-
if (UnityEngine.Time.time < movementOverride.EndTime)
51+
if (UnityEngine.Time.time < ecsMovementOverride.EndTime)
5152
{
5253
if (hasMovement)
5354
{
54-
movement.Axes = movementOverride.Axes;
55-
movement.Kind = movementOverride.Kind;
55+
movement.Axes = ecsMovementOverride.Axes;
56+
movement.Kind = ecsMovementOverride.Kind;
5657
}
5758

58-
if (movementOverride.JumpRequested)
59+
if (ecsMovementOverride.JumpRequested)
5960
{
60-
movementOverride.JumpRequested = false;
61+
ecsMovementOverride.JumpRequested = false;
6162

6263
ref JumpInputComponent jump = ref World.TryGetRef<JumpInputComponent>(playerEntity, out bool hasJump);
6364

@@ -67,17 +68,14 @@ protected override void Update(float t)
6768
}
6869
else
6970
{
70-
UniTaskCompletionSource? completion = movementOverride.Completion;
71-
7271
if (hasMovement)
7372
{
7473
movement.Axes = Vector2.zero;
7574
movement.Kind = MovementKind.IDLE;
7675
}
7776

78-
// Structural change only after all outstanding component refs are done.
79-
World.Remove<McpMovementOverride>(playerEntity);
80-
completion?.TrySetResult();
77+
// The override is copied out before the structural removal; no component refs are touched afterwards.
78+
McpRequest.CompleteAndRemove(World, playerEntity, ecsMovementOverride, AsyncUnit.Default);
8179
}
8280
}
8381
}

0 commit comments

Comments
 (0)