Skip to content

Devtools & MCP

Add one call to your app and it exposes a stable org.gjsify.Devtools control plane. From there you can take a screenshot, dump the widget tree, activate an action, swap in new CSS, and read the app’s state, from three places:

  • A shell, with gdbus, d-feet or GNOME Builder. No AI, no dev server.
  • A script, with gjsify devtools — the same plane with an argv front end, so a screenshot lands in a file.
  • An AI agent (Claude Code, the MCP Inspector) over MCP, via gjsify debug.
  • CI, where the same calls make an end-to-end UI test harness with no agent involved.

It works for GTK apps and, through the bundled Adwaita web browser, for web apps you build with GJSify.

Terminal
gjsify install @gjsify/devtools

Call installDevtools from your application’s startup handler. The bus connection and object path only exist after the application has registered, which is what startup guarantees.

TypeScript
import Adw from 'gi://Adw?version=1';
import GObject from 'gi://GObject?version=2.0';
import { installDevtools } from '@gjsify/devtools';
export class MyApplication extends Adw.Application {
static { GObject.registerClass(MyApplication); }
vfunc_startup(): void {
super.vfunc_startup();
// register your actions and create the main window first
installDevtools(this, {
// Adw.ApplicationWindow does not expose its win.* actions as a group,
// so pass it explicitly if you want win.* commands bridged.
winActionGroup: this._window,
});
}
}

It does nothing unless GJSIFY_DEVTOOLS is set, so leave it in your release build. Run with the gate on:

Terminal
GJSIFY_DEVTOOLS=1 gjsify run dist/app.gjs.mjs # an --app gjs bundle, on gjs
GJSIFY_DEVTOOLS=1 gjsify run --runtime node dist/app.node.mjs # an --app node bundle

Bun and Deno take that second bundle too, and the whole control plane is identical on all four; step 7 has the detail.

installDevtools never throws. If it cannot come up it prints why on stderr and returns null, so a devtools problem can never cost you your window.

If you use @gjsify/adwaita-app, this is already wired: pass devtools: true to force it on, or leave the option out and keep the env gate.

The same is true of @gjsify/react-native, where there is no startup handler to call anything from: registerRootComponent(App) is the whole bootstrap, and it brings devtools with it. Set GJSIFY_DEVTOOLS=1, or force it on where you register:

TypeScript
import { registerRootComponent, Text } from '@gjsify/react-native';
const App = () => <Text>Hello</Text>;
await registerRootComponent(App, {
applicationId: 'org.example.MyApp',
title: 'My App',
devtools: true,
});

DumpTree, Screenshot, ActivateWidget and the rest of this page then work against the rendered widget tree, not only the application object.

OptionWhat it does
enabledtrue force-enables regardless of GJSIFY_DEVTOOLS.
winActionGroupThe action group to bridge as win.*. Needed for Adw.ApplicationWindow.
instanceA label, so several copies of the app can be driven side by side. Defaults to GJSIFY_DEVTOOLS_INSTANCE.
addressA D-Bus address to listen on instead of the session bus. Defaults to GJSIFY_DEVTOOLS_ADDRESS.
pausedA predicate; while it returns true, mutating calls are rejected.
extendYour own app-specific methods (see Add your own commands).

The control plane is plain D-Bus, so you can poke it without any tooling:

Terminal
# A JSON snapshot: app id, active window, toplevel count, focused widget,
# pause state, plus anything an extension contributes
gdbus call --session -d org.example.App -o /org/example/App/devtools \
-m org.gjsify.Devtools.GetStatus
# The active window as PNG bytes (a GVariant `ay`; gdbus prints it as text, so
# use the MCP `screenshot` tool when you want a file on disk)
gdbus call --session -d org.example.App -o /org/example/App/devtools \
-m org.gjsify.Devtools.Screenshot 'window'
# List GActions, then invoke one
gdbus call --session -d org.example.App -o /org/example/App/devtools \
-m org.gjsify.Devtools.ListActions
gdbus call --session -d org.example.App -o /org/example/App/devtools \
-m org.gjsify.Devtools.ActivateAction 'app' 'about' ''

The object path is your application id with dots turned into slashes, plus /devtools. A hyphen is not legal in a D-Bus path element, so it becomes an underscore on the way: org.example.my-app is served at /org/example/my_app/devtools.

This is also how you write a headless UI test: script a sequence of ActivateAction, ChangeActionState and Screenshot calls, then assert on GetStatus.

gdbus cannot save a PNG, so gjsify devtools can

Section titled “gdbus cannot save a PNG, so gjsify devtools can”

Screenshot answers a GVariant ay, and gdbus prints binary as text. Unpacking that variant into a file was a per-consumer GJS script, and each one had to decide the same two things: that an EMPTY answer (ay[0], which is what a window that was never realised returns) is a failure rather than a 0-byte .png called a screenshot, and that the size to report comes from the PNG rather than from the resize it asked for. gjsify devtools is that script, once:

Terminal
gjsify devtools resize 1280 900 --bus-name org.example.App
gjsify devtools shot /tmp/app.png --bus-name org.example.App --settle 500
# wrote /tmp/app.png (84311 bytes, 1100×900)

The printed size is out of the PNG header. ResizeWindow answers with the size it was asked for, whether or not the window honoured it, so a resize the app ignored still looks like it worked — measured in a consumer: 1280 requested, 1100 in the file, four checks green.

find, activate and key are the same idea for driving a widget, and they print to stdout and exit non-zero when the app says no, which is what makes a script able to fail:

Terminal
row=$(gjsify devtools find Adw.StatusPage:error --bus-name org.example.App) || exit 1
gjsify devtools activate "$row" --bus-name org.example.App

--timeout <seconds> (default 30) polls GetStatus until the plane answers, because a fixed sleep is wrong in one direction or the other — a cold start measured 94 s before the first call was served. gjsify devtools help lists every operation and flag. From your own code, the same invariants are captureShot from @gjsify/devtools.

gjsify debug builds and launches the MCP bridge. Point your MCP client at it with a .mcp.json at the project root:

.mcp.json
{
"mcpServers": {
"my-app": { "command": "gjsify", "args": ["debug", "--bus-name", "org.example.App"] }
}
}

The typical loop:

launch the app with GJSIFY_DEVTOOLS=1, get_status to see where it is, screenshot to see it, activate_action or activate_widget to drive it, screenshot again to confirm.

To stop the client rebuilding on every launch, build once and point at the bundle:

Terminal
gjsify debug --build-only --out dist/bridge.gjs.mjs
# .mcp.json → { "mcpServers": { "my-app": { "command": "dist/bridge.gjs.mjs" } } }

The bridge picks a tool profile from your package.json dependencies (storybook, browser, cdp or generic), or you can force one with --profile.

Available in every profile. All of them take an optional instance argument when you are running more than one copy of the app.

ToolWhat it does
get_statusApp id, active window, toplevel count, focused widget, pause state.
screenshotPNG of the active window or the whole app.
list_actions / activate_action / change_action_stateEnumerate and drive GActions.
present_window / resize_windowBring the window forward; resize it for layout testing.
list_toplevelsEvery toplevel window the app owns.
dump_treeThe widget tree from a root down to a depth, with concrete GTypes.
get_property / get_focusedRead one widget property; find what has focus.
activate_widgetClick or activate a widget by its path.
dump_css / swap_cssRead the loaded CSS; replace a named provider live.
dump_gsettingsEvery key in a GSettings schema.
list_instancesThe labelled app instances currently on the bus.

4. On macOS and Windows, where there is no session bus

Section titled “4. On macOS and Windows, where there is no session bus”

Nothing extra to do. When no session bus answers, the app listens on a socket of its own and publishes the address to a file; gjsify debug reads that file and dials it. The app prints the address on stderr so you can see what happened.

To pin the address yourself, set the same value on both sides:

Terminal
GJSIFY_DEVTOOLS=1 GJSIFY_DEVTOOLS_ADDRESS=unix:path=/tmp/myapp.sock gjsify run dist/index.js
gjsify debug --address unix:path=/tmp/myapp.sock

--address also accepts unix:tmpdir=/tmp and nonce-tcp:host=127.0.0.1.

The bundled Adwaita browser renders your web app and exposes the same control plane, so an agent can drive the page itself. Launch it with --devtools:

Terminal
gjsify browse http://localhost:8080 --devtools

Then bridge it with the browser profile:

JSON
{ "mcpServers": { "browser": { "command": "gjsify", "args": ["debug", "--profile", "browser"] } } }

On top of the generic tools you get:

ToolWhat it does
navigate / back / forward / reloadDrive history; keeps the URL bar in sync.
get_page_infoCurrent page state: uri, appUrl, title, canGoBack, canGoForward, lastLoadError.
wait_for_loadWait for the next navigation to finish (ms, default 30000).
page_screenshotPNG of the rendered web content through the WebKit compositor. Use this one, not the generic screenshot, which comes back blank because WebKit composites out of process. region=full|visible.
set_viewportResize the content region for responsive testing.
eval_jsEvaluate a JS expression in the page; result comes back as JSON.
get_links / follow_linkEnumerate <a href>; click one and await the navigation.
query_domMetadata for every element matching a CSS selector.
get_consoleBuffered console.* output captured from the page.
inspect_elementTag, id, classes, attributes, bounding rect, box model and curated computed styles in one call.
dom_treeThe elements tree from a selector down to a depth.
get_networkPage network activity via the Resource Timing API.
get_accessibilityAn approximate accessibility tree (role, name, aria-*).
open_inspector / close_inspectorToggle the WebKit Web Inspector panel.
list_stories / open_story / get_current_story / set_story_argDrive a running @gjsify/adwaita-storybook page the same way as the native storybook.

Two shortcuts worth knowing:

Terminal
# One-shot capture, no window to close: load, screenshot, exit.
gjsify browse http://localhost:8080 --screenshot shot.png
# WebKit's remote inspector protocol on a port, plus the cdp profile's
# cdp_discover_targets / cdp_connect / cdp_send / cdp_drain_events tools.
gjsify browse http://localhost:8080 --inspector-port 9222

gjsify storybook speaks the same plane. Run it with GJSIFY_DEVTOOLS=1 and bridge with --profile storybook; you get list_stories, get_current_story, open_story and set_story_arg, so an agent can open a widget in isolation, flip its args and screenshot each variant.

The control plane belongs to your app, not to a runtime. One installDevtools(app, …) call, one interface, the same MCP profiles, on all four. The layer underneath is what differs: on gjs your --app gjs bundle resolves gi:// in the host itself, while Node, Bun and Deno run the same --app node bundle with gi:// going through @gjsify/node-gi.

Terminal
GJSIFY_DEVTOOLS=1 gjsify run dist/app.gjs.mjs
GJSIFY_DEVTOOLS=1 gjsify run --runtime node dist/app.node.mjs
GJSIFY_DEVTOOLS=1 gjsify run --runtime bun dist/app.node.mjs
GJSIFY_DEVTOOLS=1 gjsify run --runtime deno dist/app.node.mjs

GetStatus, DumpTree, ListToplevels and the async Screenshot produce the same results across the four, real PNGs included, and DumpTree reports the same concrete runtime GTypes whichever bundle is loaded.

Every method is classified, so a host can stop an agent driving the UI without going dark:

  • read-only (get_status, screenshot, dump_tree, inspect_element, …) is always allowed.
  • presence covers an external driver’s own awareness channel, such as a cursor or label, and is allowed.
  • mutating (activate_action, change_action_state, eval_js, navigate, swap_css, …) is rejected while paused.

Pass a paused: () => boolean predicate to installDevtools to control it.

A DevtoolsExtension adds app-specific methods to the same interface, so an agent drives them through the same bridge:

TypeScript
installDevtools(this, {
extend: [{
methodsXml: ['<method name="PaintTile"><arg type="i" direction="in" name="x"/></method>'],
handlers: { PaintTile: (x: number) => paintTile(x) },
methodKinds: { PaintTile: 'mutating' },
contributeStatus: () => ({ tiles: countTiles() }),
}],
});

Every method needs a methodKinds entry. The registry rejects an unclassified method, so a new command cannot slip past the pause policy by accident. Anything you return from contributeStatus shows up in get_status.