Skip to content

Latest commit

 

History

History
305 lines (258 loc) · 14.2 KB

File metadata and controls

305 lines (258 loc) · 14.2 KB

The Microsoft.UI.Reactor (Reactor) devtools surface is two subsystems that share a single gate. The MCP server speaks JSON-RPC over either HTTP loopback or stdio, exposing a fixed inventory of tools (tools/list, tools/call) that an external CLI, editor, or agent harness drives against the running app. The in-app dev menu — DevtoolsMenu(...), the keyboard shortcuts, the reconcile-highlight overlay, the layout-cost overlay — runs entirely in-process, observes the same component tree the user sees, and is gated by the same UseDevtools() flag. Both subsystems are zero-cost in retail: the gate evaluates a static readonly bool, and code paths gated behind it never construct elements or register ETW listeners. The most common mistake is reaching for a devtools modifier outside the gate; in retail it's still a null-returning factory and contributes nothing visual, but the code path still allocates whatever the surrounding component constructed to pass to it. Gate the construction, not just the rendering.

DevTools Internals

This page covers the internals of Reactor's devtools — the MCP server that external agents talk to, the in-app dev menu, the runtime overlays, and the gate that keeps the whole subsystem out of retail. Dev Tooling is the user-facing companion; this is the internals view.

The MCP loop

DevTools MCP loop — CLI reads lockfile, POSTs JSON-RPC tools/call, server dispatches to McpDispatcher, hops to UI thread, tool runs against live tree, returns JSON-RPC response. In-app DevtoolsMenu observes the same UseDevtools gate

The MCP server is a loopback HttpListener (or a stdio reader/writer loop) running inside the host process. On startup it picks a free port, generates a per-launch bearer token, and writes a lockfile under %TEMP%/reactor-devtools/<hash>.json advertising the endpoint, the port, the pid, the build tag, and the token. The CLI reads that lockfile to find the running app — there's no service registry, no broadcast discovery, just a file in tempdir keyed by the project's canonicalized path. Any process running as the same user can read the lockfile, present the token, and call tools.

Reference

Subsystem Owner type Source Gate
Gate hook UseDevtools() src/Reactor/Hooks/UseDevtools.cs Build-time devtools: true AND session-time --devtools app
MCP server DevtoolsMcpServer src/Reactor/Hosting/Devtools/DevtoolsMcpServer.cs Bearer token + project lockfile
Tool registry McpToolRegistry src/Reactor/Hosting/Devtools/McpToolRegistry.cs Per-tool input-schema validation
JSON-RPC dispatch McpDispatcher src/Reactor/Hosting/Devtools/McpDispatcher.cs Method allowlist + tools/call routing
CLI client McpCliClient src/Reactor.Cli/Devtools/McpCliClient.cs Reads lockfile, posts bearer-authed JSON-RPC
In-app menu DevtoolsMenu(...) factory src/Reactor/Hosting/Devtools/DevtoolsMenuFactory.cs UseDevtools() — returns Empty() when off
Overlays ReactorFeatureFlags src/Reactor/Core/ Toggle binds an ETW listener only when on

The gate

public static bool UseDevtools(this RenderContext ctx) =>
    ReactorApp.DevtoolsEnabled;

UseDevtools() returns ReactorApp.DevtoolsEnabled — the AND of two independent signals captured at process startup. The build-time signal is the devtools: true argument to ReactorApp.Run<TRoot>(...); ship a release binary without it and the AND is false no matter what flags the user passes. The session-time signal is the --devtools app (or --devtools run) command-line argument the user supplies. Both must hold for the gate to open.

The value is frozen for the session and the hook deliberately does not consume a slot in the hook table. That's why UseDevtools() can be called from helpers that aren't part of the render path — there's no positional invariant to violate. The trade-off: the value can't change at runtime. Flipping ReactorApp.DevtoolsEnabled mid-session would produce a stale gate read in every component already rendered.

Caveat: Calling UseDevtools() does not opt the component into observing the flag — the flag is frozen for the session, so observation isn't needed. But code inside the dev ? DebugOverlay() : null branch is still evaluated when the gate is open, even if your overlay never displays anything user-visible. A DebugOverlay() factory that constructs 200 children to display in a dev-only data panel is 200 allocations per render whenever devtools are enabled, including during automated UI testing that happens to launch with --devtools app. Gate construction, not just display.

The CLI client

internal sealed class McpCliClient : IDisposable
{
    private readonly HttpClient _http;
    private readonly string _endpoint;
    private readonly string? _token;

    public McpCliClient(string endpoint, TimeSpan? timeout = null, string? token = null)
    {
        _endpoint = endpoint;
        _token = token;
        _http = new HttpClient { Timeout = timeout ?? TimeSpan.FromSeconds(30) };
        if (!string.IsNullOrEmpty(token))
        {
            _http.DefaultRequestHeaders.Authorization =
                new global::System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
        }
    }

McpCliClient is the CLI-side terminus of the MCP loop. It takes an endpoint URL, an optional bearer token, and an optional timeout, and exposes InvokeTool and InvokeMethod as the two entry points; the CLI's verb commands (mur devtools components, mur devtools click, …) all layer on top of InvokeTool with the tool name baked in. The token comes from the lockfile the server wrote on startup, so the discovery flow is open-the-lockfile → read endpoint + token → construct client. The Authorization: Bearer <token> header is the only thing standing between an attacker on localhost and the running app's MCP surface — the lockfile sits in the user's tempdir so any process running as the user can read it, but processes running as other users can't.

Endpoint discovery

The lockfile under %TEMP%/reactor-devtools/<hash>.json carries the endpoint URL, the per-launch bearer token, the pid (for liveness probing), the build tag, the transport (http or stdio), and the project path. The path hash is a truncated SHA-256 of the canonicalized .csproj path — C:\foo\bar.csproj and c:/foo/bar.csproj collide deliberately, so re-launching the same project from a different shell finds the existing session rather than starting a second one. The CLI's mur devtools serve command checks for a live lockfile before binding a new port; if one exists and the process is still alive, the second invocation exits with the running session's information instead of starting a new server.

A lockfile is stale when the recorded pid doesn't match a running process. The CLI deletes stale lockfiles on read; the running server doesn't proactively re-write its lockfile, but it also doesn't need to — the file's pid field is the only check that matters, and the pid doesn't change for the life of the session.

Tool dispatch — the UI-thread hop

private object? HandleCall(JsonElement? @params)
{
    if (@params is not { } p || p.ValueKind != JsonValueKind.Object)
        throw new McpToolException("tools/call params must be an object with { name, arguments? }.",
            JsonRpcErrorCodes.InvalidParams);
    if (!p.TryGetProperty("name", out var nameEl) || nameEl.ValueKind != JsonValueKind.String)
        throw new McpToolException("tools/call requires a string 'name' field.", JsonRpcErrorCodes.InvalidParams);

    var name = nameEl.GetString()!;
    JsonElement? args = p.TryGetProperty("arguments", out var argsEl) ? argsEl : null;
    return Invoke(name, args);
}

Tool handlers run on the UI dispatcher, not on the HTTP listener's worker thread. The MCP server takes the parsed request, looks up the handler in the McpToolRegistry, and posts the handler call through DispatcherQueue.TryEnqueue. That hop is what makes it safe for a handler to query the live WinUI tree — WindowRegistry.Snapshot(), host.Mount(new T()), every property read on a FrameworkElement — all of which assume the calling thread is the one that owns the visual tree. Without the hop, the handler would race against the UI thread and either throw COMException or produce a stale snapshot.

The dispatch path is the same for HTTP and stdio. The two transports diverge only at the read boundary (HttpListenerContext vs StreamReader) and the write boundary (HttpListenerResponse vs StreamWriter). Everything between — JSON-RPC parsing, the McpDispatcher.Dispatch(body) call, the dispatcher hop, the tool handler — is shared code.

The in-app menu

DevtoolsMenu(...) is the in-app counterpart to the MCP server: a titlebar widget that shows up only when UseDevtools() is true and hosts a flyout of MenuFlyoutItemBase items the host app supplies. The pattern is static readonly Observable<bool> fields in an AppFlags class, mutated by ToggleMenuItem callbacks, observed by components via ctx.UseObservable(AppFlags.DebugUI).Value. The DevtoolsMenu factory also appends a built-in "Highlight reconcile changes" toggle that drives the overlay subsystem.

When the gate is closed, the factory returns Empty() and never invokes the items lambda — every menu construction inside the lambda is skipped at retail cost of one bool check. That asymmetry matters: callers can freely allocate Observable<bool> fields, construct MenuItem records, and read state from the surrounding component without paying for any of it when the app ships without the gate flipped.

Overlays

The reconcile-highlight overlay and the layout-cost overlay both attach to a running app when the gate is open. They're driven by ReactorFeatureFlags.HighlightReconcileChanges and ReactorFeatureFlags.LayoutCostOverlay, both of which are simple static bool properties the dev menu toggles. The overlays themselves are stateless wrappers that subscribe to the ETW provider only while their flag is true — the listener registration is the consuming cost. Toggle the flag back off and the subscription is dropped on the next render cycle.

The reconcile overlay paints a brief flash on every element the reconciler patched on the most recent render; the layout-cost overlay tints elements by their last measure / arrange time sourced from the Microsoft-UI-Reactor ETW provider's reconcile / layout events. Neither one allocates per frame when its flag is off.

Patterns

Adding a custom MCP tool

The MCP tool surface is open for extension at host bring-up. Any code with access to the DevtoolsMcpServer instance can call server.Tools.Register(...). The shape is a descriptor (name, description, JSON input schema) plus a handler delegate that receives the parsed params element and returns a JSON-serializable result:

server.Tools.Register(
    new McpToolDescriptor(
        Name: "appStats",
        Description: "Returns the running app's render and reconcile counters.",
        InputSchema: new { type = "object", properties = new { }, additionalProperties = false }),
    _ => new
    {
        renders = AppStats.RenderCount,
        reconciles = AppStats.ReconcileCount,
        avgRenderUs = AppStats.AvgRenderMicroseconds(),
    });

The tool shows up in tools/list on the next call, with the input schema echoed back to agents that introspect their tool inventory. Handlers that touch WinUI state must run inside a DispatcherQueue.TryEnqueue block — the registry's dispatch is already on the UI thread, but the handler will run on the listener thread if the registration uses the synchronous shape. See the existing windows.activate registration in src/Reactor/Hosting/Devtools/DevtoolsTools.cs for the dispatcher trampoline pattern.

Common Mistakes

Reaching for UseDevtools to gate a render-time decision

// Don't:
public override Element Render()
{
    var (dev, _) = UseState(UseDevtools());  // captures once, never refreshes
    return dev ? VStack(...) : VStack(...);
}
public static bool UseDevtools(this RenderContext ctx) =>
    ReactorApp.DevtoolsEnabled;

UseDevtools() is already a static read; wrapping it in UseState gains nothing and loses the contract: the state cell only captures the value once and ignores subsequent session changes. Just call UseDevtools() inline at the branch point — it's a static readonly bool read, allocation-free, and the value doesn't change for the life of the session anyway.

Tips

The lockfile is the discovery contract. When an agent or CLI can't find a running app, the first thing to check is whether the lockfile exists at %TEMP%/reactor-devtools/. Missing means the session didn't pass the build-time gate. Present but stale means the previous session crashed; deleting the file unblocks the next launch.

Observable<bool> is the dev-flag pattern. Plain static bool fields don't notify, so toggling them from the dev menu doesn't re-render components that observe them. The devtools-menu cookbook in dev-tooling.md uses static readonly Observable<bool> fields exactly so the UseObservable hook can pick up changes.

Tools that mutate must hop the dispatcher. The MCP server dispatch lands on the UI thread by default through the DispatcherQueue.TryEnqueue call in the dispatcher, but custom handlers registered via server.Tools.Register are responsible for making sure any reconciler-touching code path runs on the same thread.

Next Steps