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¶
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¶
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 thedev ? DebugOverlay() : nullbranch is still evaluated when the gate is open, even if your overlay never displays anything user-visible. ADebugOverlay()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(...);
}
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¶
- Dev Tooling — User-facing devtools API and the inner-loop story.
- Perf Instrumentation — ETW provider the overlays consume.
- Hooks Internals — Why
UseDevtoolsdoesn't consume a slot. - Reconciliation — How the reconcile-highlight overlay observes patches.
- Focus and Input Internals — Previous under-the-hood page.