Skip to content

Debugging & remote control (DBus + MCP)

gjsify ships a devtools control plane: opt your app in with one call and it exposes a stable org.gjsify.Devtools interface you can drive three ways —

  • By hand with gdbus / d-feet / GNOME Builder — no AI, no dev server.
  • From an AI agent (Claude Code, the MCP Inspector, …) over MCP, via gjsify debug.
  • Headless in CI — the same calls make a no-AI end-to-end UI test harness.

It works for GTK apps (DBus is the idiomatic GNOME transport) and, through the bundled Adwaita web browser, for web apps you build with gjsify too. The contract — commands + state + introspection — is the same across both.

PackageRole
@gjsify/devtools-protocolThe transport-agnostic contract (pure TS): methods, pause classification, envelope, interface name.
@gjsify/devtoolsThe in-app DBus adapter for GTK/GJS. installDevtools(app, …).
@gjsify/devtools-mcpThe MCP bridge an agent talks to. Powers gjsify debug.
@gjsify/devtools-browserThe minimalist Adwaita web browser for debugging gjsify web apps. Powers gjsify browse.

Two CLI commands tie it together: gjsify debug (the MCP bridge) and gjsify browse (the web browser). The GTK gjsify storybook is debuggable through the same plane.

Add the dependency and call installDevtools from startup. It is a no-op unless GJSIFY_DEVTOOLS=1 (or you pass enabled: true), so it is safe to leave in release builds.

import Adw from '@girs/adw-1';
import GObject from '@girs/gobject-2.0';
import { installDevtools } from '@gjsify/devtools';
export class MyApplication extends Adw.Application {
static { GObject.registerClass(MyApplication); }
vfunc_startup(): void {
super.vfunc_startup();
// … register actions + create the main window first …
installDevtools(this, {
// Adw.ApplicationWindow does not expose its win.* actions as a group —
// pass it explicitly so win.* commands are bridged.
winActionGroup: this._window,
});
}
}

Run it with the gate on:

GJSIFY_DEVTOOLS=1 gjsify run dist/index.js

The control plane is plain DBus, so you can poke it from a shell:

# A JSON snapshot: app id, active window, toplevel count, focused widget,
# pause state (+ any keys an extension contributes via contributeStatus)
gdbus call --session -d org.example.App -o /org/example/App/devtools \
-m org.gjsify.Devtools.GetStatus
# The active window as PNG bytes (returned as a GVariant `ay`; gdbus prints
# them as text — use the MCP `screenshot` tool for a ready-to-save PNG file)
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' ''

This is also the basis for headless UI tests: script a sequence of ActivateAction / ChangeActionState / Screenshot calls and assert on GetStatus — no agent required.

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"] }
}
}

Now an agent has tools like get_status, screenshot, list_actions, activate_action, change_action_state, resize_window, plus introspection (dump_tree, get_property). The typical loop:

launch the app with GJSIFY_DEVTOOLS=1get_status to see where it is → screenshot to see it → activate_action / change_action_state to drive it → screenshot again to confirm.

For a fixed binary path (so the client doesn’t rebuild on every launch), build once and point at the bundle:

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

The bridge auto-detects a tool profile from your package.json dependencies — storybook, browser, or generic — or you can force one with --profile.

Because the apps you debug are themselves built with gjsify, you can render them in the bundled Adwaita web browser and drive its devtools plane. Launch with --devtools:

gjsify browse https://localhost:8080 --devtools

Then bridge it with the browser profile:

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

The browser profile adds web-debugging tools on top of the generics:

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 via the WebKit compositor (the generic screenshot is blank — WebKit composites out-of-process). region=full|visible.
set_viewportResize the content region for responsive testing.
eval_jsEvaluate a JS expression in the page; JSON round-trip.
get_links / follow_linkEnumerate <a href>; click + await the navigation.
query_domMetadata for elements matching a CSS selector.
get_consoleBuffered console.* output captured from the page.
inspect_elementTag/id/class + attributes + bounding rect + box model + curated computed styles (Elements + Computed + Box Model 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.

This is purpose-built for the gjsify web-app workflow: a developer (or an agent) opens the app, screenshots the real rendered output, evaluates assertions in-page, and inspects layout/network/a11y — without a separate browser-automation stack.

gjsify storybook is debuggable the same way. Run it with GJSIFY_DEVTOOLS=1 and bridge with --profile storybook; the agent gets list_stories, get_current_story, open_story, and set_story_arg — drive a component in isolation, flip its args, and screenshot (the generic tool) each variant.

Every method is classified so a host can pause external control without going dark:

  • read-only (get_status, screenshot, dump_tree, inspect_element, …) — always allowed.
  • presence — an external driver’s own awareness channel (cursor/label) — allowed.
  • mutating (activate_action, change_action_state, eval_js, navigate, swap_css, …) — rejected while paused.

The registry rejects an unclassified method, so a new app-specific method can’t silently bypass the policy. Add your own methods with a DevtoolsExtension (see the @gjsify/devtools README).