Build an MCP server that exposes QML SceneGraph introspection data (from GammaRay's probe) to LLMs for assisted debugging. The bridge is a **GammaRay client**: it connects to a probe injected into the target Qt/QML app, reads the same models the GammaRay GUI uses, and translates them into MCP JSON-RPC tool calls.
│ (common/protocol.h — QDataStream-based, NOT JSON-RPC)
▼
QML SceneGraph MCP Bridge (new project, GPL-2.0-or-later)
│ Links gammaray_client + gammaray_common (installed from source)
│ Uses ClientConnectionManager to connect, ObjectBroker to get models
│ Connection is lazy: bridge starts without a probe, MCP client calls
│ connectProbe(host, port) to establish; auto-reconnects on probe restart.
│
│ MCP JSON-RPC over stdio (or HTTP+SSE)
▼
LLM / AI Agent
```
The bridge is a GammaRay client peer to `gammaray-client` (GUI client) and `gammaray` (launcher+client GUI). It does NOT touch the probe side; it reuses GammaRay's existing probe injection + protocol + model sync.
## License
- GammaRay libraries are **GPL-2.0-or-later**. Linking `gammaray_client` makes the bridge a derivative work → **bridge must be GPL-compatible**.
- Accepted by user. No commercial license needed.
## GammaRay source location
The GammaRay source used for investigation: `/home/blumia/Sources/GammaRay` (version 3.4.0 per `version.txt`).
## Step 1: Build and install GammaRay from source [DONE]
System packages are too old (system has 2.11.3); protocol version must match between probe and client (`common/protocol.h:141``Protocol::version()`). Built both probe and client libs from the 3.4.0 source.
Verified at runtime: `Protocol::version()` = 38, `Endpoint::defaultPort()` = 11732. `gammaray_client.so` runtime-links `libgammaray_ui-qt6_8-x86_64.so` (PRIVATE link, as the plan predicted) → `install-prefix/lib` must be on `LD_LIBRARY_PATH` at runtime.
**VERIFIED — exported target namespace (gotcha #8)**: `CMakeLists.txt:931-932` has `NAMESPACE GammaRay::` commented out. Confirmed in installed `GammaRayTarget.cmake:22`: targets are **unnamespaced** — `gammaray_client`, `gammaray_common`, `gammaray_core`, `gammaray_ui`, `gammaray_kitemmodels`, `gammaray_launcher`, `gammaray_launcher_ui`, `gammaray_kuserfeedback`, `gammaray_probe`. Use `gammaray_client` / `gammaray_common` directly (no `GammaRay::` prefix).
## Step 2: Create the bridge project
New working directory, separate from GammaRay source tree.
### Dependencies
1.**GammaRay** — installed from source (Step 1), provides `gammaray_client`, `gammaray_common`
- License: `LGPL-3.0 OR GPL-2.0 OR GPL-3.0` — choose GPL-2.0 to be compatible with GammaRay's GPL-2.0-or-later
### qtmcp — consumed via FetchContent (no separate clone/install needed)
**VERIFIED**: qtmcp works with `FetchContent_MakeAvailable()` despite using Qt BuildInternals (`qt_internal_project_setup()` + `qt_build_repo()`). Tested empirically: configure + build + link all succeed. The resulting targets are `Qt6::McpServer`, `Qt6::McpCommon`, `Qt6::McpClient` (with `Qt::` alias). Headers are generated into the consumer's build tree (`${CMAKE_BINARY_DIR}/include/QtMcpServer/`).
Skip examples/tests with the **Qt-prefixed** vars (not `BUILD_EXAMPLES`): `-DQT_BUILD_EXAMPLES=OFF -DQT_BUILD_TESTS=OFF` — verified to suppress the examples/ build dir.
Caveats of the FetchContent approach:
- qtmcp builds **shared** libs (`libQt6McpServer.so.6.10.2`) into the consumer's build tree. The bridge exe needs RPATH or `LD_LIBRARY_PATH` pointing at `${CMAKE_BINARY_DIR}/lib/x86_64-linux-gnu/` at runtime. For a distributable bridge, either install qtmcp or switch to static (`-DBUILD_SHARED_LIBS=OFF`, verify).
- MCP backend plugins (stdio, sse) are built as Qt plugins under `${CMAKE_BINARY_DIR}/lib/x86_64-linux-gnu/qt6/plugins/mcpserverbackend/`. The bridge process must find them via the Qt plugin path / `QT_PLUGIN_PATH` at runtime, otherwise `QMcpServer("stdio")` can't load the stdio backend.
- qtmcp version at time of verification: `6.10.2` (alpha1 prerelease, from `.cmake.conf`). Pin `GIT_TAG` to a commit/tag for reproducibility rather than tracking `main`.
**VERIFIED the hard way**: `ObjectBroker::model()` on the client side goes through `ClientConnectionManager`'s model factory (`client/clientconnectionmanager.cpp:44`) which constructs a `RemoteModel`. `RemoteModel`'s constructor (`client/remotemodel.cpp:97`) calls `QApplication::style()->sizeFromContents(...)`. With a plain `QCoreApplication` there is no style → **segfault** the moment `ready()` fires and the first model is requested.
Fix: use `QApplication` (which pulls in `Qt6::Widgets`). This is consistent with `client/main.cpp:38` (the standalone GammaRay client uses `QApplication`), and `gammaray_client.so` already runtime-links `libQt6Widgets.so.6`. Run the bridge headless with `QT_QPA_PLATFORM=offscreen`.
### MCP server skeleton (using qtmcp)
```cpp
#include <QApplication> // NOT QCoreApplication — see note above
**VERIFIED** against `src/mcpserver/qmcpserversession.cpp` (`callTool()` switch on `mm.returnMetaType().id()`): only `Void`/`Bool`/`QString`/`QStringList`/`QImage` (sync) + `QFuture<QList<QMcpCallToolResultContent>>` (async) are handled; the `default:` branch calls `qFatal()`. The plan's earlier `QJsonObject` / `QFuture<QString>` sketches were wrong and would have crashed.
### qtmcp target names — VERIFIED
FetchContent produces targets `Qt6::McpServer`, `Qt6::McpCommon`, `Qt6::McpClient` (with `Qt::` alias). Headers generated at `${CMAKE_BINARY_DIR}/include/QtMcpServer/` etc. The include `#include <QtMcpServer/QMcpServer>` works once `Qt6::McpServer` is linked. Note `QMcpServer` ctor takes the backend name explicitly: `QMcpServer server(QStringLiteral("stdio"));` (no default ctor).
`ClientConnectionManager::ready()` is emitted after protocol handshake + object map sync — models are only available after this. Do NOT access models before `ready()`. **VERIFIED**: against a running 3.4.0 probe (qml target), `ready()` fires and `ObjectBroker::model("com.kdab.GammaRay.QuickSceneGraphModel")` returns a non-null `RemoteModel*` — full handshake works with matching protocol version 38.
## Step 4: Available data and model registration names
All names verified from `plugins/quickinspector/quickinspector.cpp` and extension `.cpp` files. These are the strings to pass to `ObjectBroker::model(QString)`.
### Models (tree/tabular data via RemoteModel)
| Model name | Source | Description |
|---|---|---|
| `com.kdab.GammaRay.QuickWindowModel` | quickinspector.cpp:347 | List of QQuickWindows |
| `com.kdab.GammaRay.QuickItemModel` | quickinspector.cpp:352 | QQuickItem tree |
Model name pattern for property-controller models: `<objectBaseName>.<nameSuffix>` where objectBaseName is `com.kdab.GammaRay.QuickSceneGraph` or `com.kdab.GammaRay.QuickItem` (quickinspector.cpp:327-329). The suffix is registered via `PropertyController::registerModel(model, nameSuffix)` (core/propertycontroller.cpp:62-64).
### Remote objects (interfaces via ObjectBroker::object<T>())
The GUI client registers factories via `ObjectBroker::registerClientObjectFactoryCallback<T>()` (quickinspectorwidget.cpp:82, 268). The bridge must do the same for interfaces it wants to use:
return new QuickInspectorClient(parent); // or own impl
});
```
Check whether `QuickInspectorClient` (quickinspectorclient.h) is exported/installed. If not, may need a thin reimplementation calling `Endpoint::invokeObject()` directly. Verify after install by checking installed headers in `$INSTALL_PREFIX/include/gammaray/`.
**RESOLVED (open question #1)**: `QuickInspectorClient`, `QuickInspectorInterface`, `MaterialExtensionInterface` and all other `plugins/quickinspector/*` headers are **NOT** in `install-prefix/include/gammaray/`. Only `client/` (3 headers: `clientconnectionmanager.h`, `processtracker.h`, `gammaray_client_export.h`), `common/`, `core/`, `launcher/`, `ui/` are installed. The plugin client-side classes live only in the GammaRay source tree (`plugins/quickinspector/`) and are compiled into the per-tool `gammaray_quickinspector` client plugin `.so` that the *GUI* client loads dynamically. The bridge does NOT load these plugins. **Good news (verified)**: `Endpoint::invokeObject(name, method, args)` is PUBLIC in the installed `endpoint.h`, and it only needs the name→address mapping (synced at handshake) — NOT a local stub object. So the bridge can call probe-side interface methods directly by IID + bare slot name, with zero stub code. See the "Key implementation findings" in the Verification status section for the exact name (`com.kdab.GammaRay.QuickInspectorInterface/1.0`) and method (`"selectWindow"`) conventions. Only for *receiving* signals (e.g. `MaterialExtensionInterface::gotShader`) would a real client stub be needed — handled case-by-case when implementing those tools.
- Updates: refreshed on `QQuickWindow::afterRendering` signal
- Node deletion: `QuickSceneGraphModel::nodeDeleted(QSGNode*)` signal
**Caveat**: QSGNode* values in the model are raw pointers in the probe's process. They cannot be dereferenced from the client. Use them only as opaque IDs (via ObjectId) for selection and model navigation. Actual geometry/material/texture data must be fetched through the corresponding models/extensions.
## Step 6: Suggested MCP tools
Map GammaRay client capabilities to MCP tool calls.
### Connection management tools (NEW — implemented)
-`connectProbe(host?, port?)` → `GammaRaySession::connectToHost(tcp://host:port)`, block up to 6s for handshake, return `{connected, state, url, error?}`. Defaults `127.0.0.1:11732`. URL remembered for auto-reconnect.
-`connectProbeDefault()` → convenience wrapper for `connectProbe(127.0.0.1, 11732)` (qtmcp doesn't support arg-overloading, so two tool entries).
-`probeStatus()` → return `{state, ready, url, error?}`. No side effects.
-`disconnectProbe()` → drop connection, clear remembered URL (stops auto-reconnect).
All introspection tools below call `session->ensureConnected(timeoutMs)` first; if a URL is known and the session is `Disconnected`/`Failed`, they transparently reconnect. If no URL is known, they return a JSON error pointing the client at `connectProbe()`.
- ✅ `selectQuickWindow(index)` — forwards via `Endpoint::invokeObject("com.kdab.GammaRay.QuickInspectorInterface/1.0", "selectWindow", {index})`.
- ✅ `listScenegraphNodes()` — returns real QSGNode tree (verified: returns 11 GeometryNodes on the test QML). Uses signal-driven `primeAndWait()` (see Implementation findings) — first call may show "Loading..." for deep nodes, second call returns the full tree.
- ✅ `setSlowMode(enabled)` — calls `invokeObject(..., "setSlowMode", {bool})`. Verified by reading back state.
**Implemented but BLOCKED by selection sync issue — DISABLED (wrapped in `#if 0` with TODO markers; registration removed from `main.cpp`). Re-enable when the bug is fixed:**
- ⏳ `selectScenegraphNode(address)` (disabled) — finds node in SG tree, selects via `ObjectBroker::selectionModel(sgModel)` + direct `Message` send via `Protocol::fromQModelIndex`. Caches `m_selectedAddress` to avoid redundant reselection. Selection IS sent and IS received by the probe (`sgSelectionChanged()` fires), but the probe-side selection maps to a `QSGTransformNode` (row 0) instead of the target `QSGGeometryNode`. Sub-models stay empty.
- ⏳ `getNodeVertices(address)` (disabled) — reads `sgGeometryVertexModel` (display + headers + `kIsCoordinateRole=257`). Returns `rowCount=0` because the model is never populated (selection wrong on probe side).
- ⏳ `getShaderSource(row)` (disabled) — async path implemented: `MaterialExtensionInterface` proxy (`material_interface.{h,cpp}`) with matching IID `com.kdab.GammaRay.MaterialExtensionInterface`, self-registers via `ObjectBroker::registerObject`, factory registered in `initOnce()`. Calls `invokeObject(..., "getShader", {row})` and waits for `gotShader(QString)` signal. **Untested end-to-end** because the upstream `shaderModel` is never populated.
- ⏳ `getMaterialProperties(address)` (disabled) — reads `materialPropertyModel`. Same blocker.
**Not yet implemented:**
- ❌ `grabTexture(objectId)` — `TextureExtension` async image grab via `RemoteViewInterface``TransferImage`. Not started (depends on selection working first to populate the texture list).
### Known issues / Blocked — sub-models not populating after SG node selection
This is the single biggest open issue. Extensive debugging has narrowed it down but not yet fixed it.
**Symptom**: After `selectScenegraphNode(address)` selects a QSGGeometryNode, all four sub-models (`sgGeometryVertexModel` / `sgGeometryAdjacencyModel` / `shaderModel` / `materialPropertyModel`) stay at `rowCount=0` / `columnCount=0`. Tested against all 11 GeometryNodes in the test QML — none populate.
**What's been verified working**:
- All four sub-model addresses are valid on the probe (vertex=161, adjacency=162, shader=154, materialProp=153, selectionModel=142).
- The client-side selection model name is correct: `"com.kdab.GammaRay.QuickSceneGraphModel.selection"` (NOT `".selection"` — `_diag_dotSelAddr=0` confirms the dotted form is wrong).
-`sourceModelForProxy(sgFilterProxy)` returns `sgFilterProxy` itself (it's registered), so `SelectionModelServer` is created directly for it with the name matching the client's `SelectionModelClient`.
- Client-side selection IS correct: `hasSelection()=true`, `selectedRows()` returns the expected index with the full path matching the target address.
-`Endpoint::isConnected()=true` — selection messages ARE being sent.
- Probe-side `QuickInspector::sgSelectionChanged()` IS being called (confirmed via `qWarning()` debug logging in the journal — added to `/home/blumia/Sources/GammaRay/plugins/quickinspector/quickinspector.cpp:737`).
**Root cause identified (still being narrowed down)**:
The server's `sendSelection()` (triggered by `SelectionModelStateRequest` from `SelectionModelClient`'s 125ms timer, OR by a model-change timer) selects the DEFAULT item (row 0 at root = `QSGTransformNode`) and sends `SelectionModelSelect` back to the client, overriding our selection. Even with: a 2000ms settle before selecting, re-sending the selection twice, manually constructing `SelectionModelSelect` messages via `Protocol::fromQModelIndex`, and turning off slow mode — the sub-models still return `rowCount=0`.
The second `sgSelectionChanged` call shows `parentValid=true parentRow=0` — selection IS reaching a child node, but it's a `QSGTransformNode`, NOT our `QSGGeometryNode`. The path (0,0)→(0,0)→...→(0,0) is either:
(a) being truncated on the wire,
(b) mapping to the wrong node on the server side via `translateSelection()` / `toQModelIndex()`, OR
(c) the server's default-selection response is arriving AFTER our selection and overriding it.
**Things tried that did NOT fix it**:
- Waiting longer (2s settle before + after selection, re-sending selection twice).
- Disabling slow mode before selection.
- Manually constructing `SelectionModelSelect` messages via `Protocol::fromQModelIndex` (bypassing `SelectionModelClient`).
- Turning off slow mode.
**Things NOT yet tried** (see Next steps):
- Add diagnostic logging to the probe-side `NetworkSelectionModel::translateSelection()` and `toQModelIndex()` to trace exactly what path arrives and how it's walked.
- Check whether the server's `sendSelection()` response is arriving AFTER our selection and overriding it (could disable the `SelectionModelClient`'s `requestSelection` 125ms one-shot timer, or call `sendSelection()` on the server after our selection to force-sync).
- Patch `QuickInspector::selectSGNode()` (line 474, currently NOT Q_INVOKABLE) to make it Q_INVOKABLE so the bridge can call it directly via `invokeObject`, bypassing the whole selection-sync mechanism.
- Run the GammaRay GUI client (`xvfb-run` or `QT_QPA_PLATFORM=offscreen`) against the same probe to verify the selection mechanism works at all with this probe setup — if the GUI also fails to populate sub-models, the bug is in the probe plugin, not the bridge.
### Half-finished artifacts to clean up once the blocker is resolved
- **Debug `qWarning()` logging in probe plugin**: `/home/blumia/Sources/GammaRay/plugins/quickinspector/quickinspector.cpp:737` (`SGDBG:` lines). Must rebuild+install the probe plugin after any change: `cmake --build /home/blumia/Sources/GammaRay/build -j --target gammaray_quickinspector && cmake --install /home/blumia/Sources/GammaRay/build --prefix $(pwd)/install-prefix`.
- **`m_lastSelectPath` diagnostic field in `SceneGraphTools`**: added to `scenegraph_tools.{h,cpp}` to log the serialized selection path. Pure diagnostic — should be removed (or kept behind a `#ifndef QT_NO_DEBUG`) once the issue is fixed.
- **Disabled tool registrations in `main.cpp` + `scenegraph_tools.{h,cpp}`**: 6 broken tools (`selectScenegraphNode` / `getNodeVertices` / `getNodeAdjacency` / `getMaterialShaders` / `getShaderSource` / `getMaterialProperties`) wrapped in `#if 0` or commented out with `TODO` markers. Remove the `#if 0` / uncomment when the blocker is fixed.
-`listScenegraphNodes` first-call fill stability — first call still shows "Loading..." for deep nodes; second call returns the full tree. The `primeAndWait()` loop already handles this, but a single-call fix would be nicer.
**Phase 1 (now): lazy connect + explicit `connectProbe` tool + auto-reconnect.** The bridge does NOT hard-require a probe URL at startup. A `--connect <url>` (or `GAMMARAY_PROBE_URL` env) is treated as a *default* URL — the bridge tries it best-effort on startup but won't fail if the probe isn't up yet. The MCP client drives connection lifecycle via three tools:
-`connectProbe(host?, port?)` — establish a connection to a probe (defaults `127.0.0.1:11732`). Blocks up to ~6s for the handshake. Returns JSON `{connected, state, url, error?}`. The URL is remembered for subsequent auto-reconnect.
-`probeStatus()` — report `{state, ready, url, error?}` without side effects.
-`disconnectProbe()` — drop the current connection and forget the URL (auto-reconnect stops until `connectProbe` is called again).
Every introspection tool (`listQuickWindows`, `selectQuickWindow`, `listScenegraphNodes`, …) calls `session->ensureConnected(timeoutMs)` first: if a URL is known and the session is `Disconnected`/`Failed`, it transparently re-issues `connectToHost(url)` and waits for `ready()`. If no URL is known, the tool returns a JSON error pointing the user at `connectProbe()`. So in practice a client only needs to call `connectProbe` once after starting the probe; subsequent tool calls keep working across probe restarts.
Rationale: opencode (and other MCP hosts) launch the bridge subprocess at host startup, typically BEFORE the developer has started the target app + probe. A hard `--connect` either succeeds by luck or leaves the bridge in a permanently-broken state (the original scaffold's failure mode). The lazy-connect model lets the bridge come up clean and wait for the client to point it at a probe — and recover automatically when the probe is restarted mid-session (common during iterative QML debugging).
**Why not auto-discovery (mDNS)?** GammaRay's probe does broadcast via mDNS, which would let the bridge find probes with zero configuration. But (a) multiple probes on a dev machine are common, and silently picking one is wrong; (b) mDNS on Linux is flaky across sandboxes/containers; (c) the explicit `connectProbe` is one cheap MCP call and gives the client full control. mDNS can be added later as an optional `discoverProbes()` tool if needed.
**Auto-reconnect details**: `ClientConnectionManager` already retries `transientConnectionError` (host not yet up) every 1s for 60s before emitting `persistentConnectionError` — so the INITIAL handshake has built-in resilience. But a `disconnected` signal (probe killed / target crashed MID-session) is NOT auto-retried by the manager. The bridge adds its own 1s-backoff reconnect on `disconnected` (guarded against re-entrancy and disabled after explicit `disconnectProbe()`). On `persistentConnectionError` the bridge does NOT auto-retry (GammaRay already did for 60s); the user calls `connectProbe()` again once the probe is back. State machine: `Disconnected → Connecting → Ready`, with `→ Failed` on persistent error (recoverable only via `connectProbe()`).
**Phase 2 (future, not in scope now): hybrid mode.** Bridge could also expose `attach(pid)` / `launch(path)` MCP tools for LLM-driven dynamic switching using `launcher/core/launcher.h` and `launcher/core/clientlauncher.h`. Out of scope for now.
### Phase 1 startup sequence
```bash
# 1. Developer starts target app with probe (manual)
# 2. MCP host launches bridge (stdio). --connect is OPTIONAL: if given, it's
# used as a default URL and tried best-effort at startup; if omitted, the
# client must call connectProbe() before any introspection tool returns data.
# Either way, tools auto-reconnect across probe restarts.
./run.sh # no --connect required
# or: ./run.sh --connect tcp://127.0.0.1:11732
# 3. (MCP client) call connectProbe(host, port) once the probe is up — or just
# call listQuickWindows/etc. and the bridge will return a JSON error hinting
# at connectProbe if no URL is configured.
```
For headless/CI launching of the probe, `systemd-run --user --no-block` (with `StandardOutput=file:...` + `Environment=...` properties) is a reliable way to fully detach the probe so the launching shell returns immediately — plain `&`/`disown` backgrounding tends to hang the parent shell's stdout pipe.
Bridge accepts an optional default probe URL via `--connect <url>` (or `GAMMARAY_PROBE_URL` env). At runtime the connection is driven by `connectProbe`/`disconnectProbe` MCP tools; the URL is remembered so tools can auto-reconnect after a probe restart. The bridge's `GammaRaySession` state machine (`Disconnected → Connecting → Ready`, with `→ Failed` on persistent error) is the single source of truth; `probeStatus()` reports it to the client.
The bridge acts as an MCP server on stdio. The LLM host (e.g. Claude Desktop, opencode) launches the bridge as a subprocess and communicates via JSON-RPC over stdio.
## Key gotchas to remember
1.**Protocol version lock**: probe and client must be same GammaRay build. System package too old → build from 3.4.0 source. (`common/protocol.h:141`)
2.**GPL-2.0-or-later**: bridge linking gammaray_client must be GPL-compatible. Accepted. qtmcp offers GPL-2.0-only option — compatible.
3.**Qt private headers**: GammaRay build needs `qt6-base-private-dev`, `qt6-declarative-private-dev`. The bridge itself only needs public Qt + installed GammaRay headers. **qtmcp requires Qt 6.8+** (newer than GammaRay's 6.5+ floor); ensure build environment Qt >= 6.8.
4.**C++20**: qtmcp requires C++20. Bridge project set to C++20; GammaRay C++17 libs link fine.
5.**Models only available after `ClientConnectionManager::ready()`** — do not call `ObjectBroker::model()` before handshake completes. MCP tool calls arriving before probe connection should return an error or wait.
6.**QSGNode pointers are opaque IDs on client side** — cannot dereference. Use ObjectId for selection/navigation; fetch real data via property/extension models.
7.**Async interfaces**: `MaterialExtensionInterface::getShader()` returns via signal `gotShader(QString)`, not synchronously. Texture grabs return via `RemoteViewInterface`. Bridge must handle async and correlate to MCP requests. qtmcp supports `QFuture<Result>` return types for async tool handlers.
8.**Target namespace**: exported CMake targets may lack `GammaRay::` prefix (NAMESPACE commented out at CMakeLists.txt:931). Verify in installed `GammaRayTarget.cmake`. Same applies to qtmcp installed targets — verify after building qtmcp.
9.**OpenGL dependency**: SG geometry/material/texture extensions only build when `QT_NO_OPENGL` is not set (quickinspector/CMakeLists.txt:47-66). If headless/no-GL build, these models won't exist. Ensure GammaRay is built WITH OpenGL (default).
10.**In-process mode exists** (`gammaray --inprocess`): probe + client in same process via `gammaray_inprocessui` module. Not useful for bridge (bridge IS the client). Use TCP/local socket mode.
11.**Probe ABI**: probes are versioned by Qt ABI. `GAMMARAY_MULTI_BUILD=ON` (default) builds multiple. The launcher picks the right one. Bridge doesn't need to worry about probe ABI, only client ABI matching the installed `gammaray_client`.
12.**QApplication required (NEW, verified)**: the client-side `RemoteModel` ctor calls `QApplication::style()->sizeFromContents()`. A `QCoreApplication`-only bridge segfaults on the first `ObjectBroker::model()` call after `ready()`. Use `QApplication` + `QT_QPA_PLATFORM=offscreen` for the headless bridge.
13.**qtmcp shared libs + plugin path (NEW, verified)**: under FetchContent, `libQt6Mcp*.so` and the `mcpserverbackend/libqmcpserverstdio.so` plugin land in the consumer's build tree. Set `LD_LIBRARY_PATH` and `QT_PLUGIN_PATH` (or RPATH) so the bridge finds them at runtime, else `QMcpServer("stdio")` can't load the stdio backend.
## Files worth re-reading in GammaRay source
When in doubt, these files have the ground truth:
-`plugins/quickinspector/quickinspector.cpp:322-402` — model + object registration names
-`plugins/quickinspector/quickscenegraphmodel.{h,cpp}` — SG node tree model
## Open questions to resolve during implementation
1.~~Are `QuickInspectorClient` and `MaterialExtensionClient` classes exported in installed headers?~~**RESOLVED**: No. See Step 4 note above — bridge needs own thin client stubs calling `Endpoint::invokeObject()`.
2. Exact `ObjectModel` role enum values — read `common/objectmodel.h` when implementing. (Installed at `install-prefix/include/gammaray/common/objectmodel.h`.)
3. How does `RemoteModel` (client/remotemodel.h) expose data? It's a QAbstractItemModel proxying the server model. Standard model API applies. **VERIFIED**: `ObjectBroker::model()` returns a non-null `QAbstractItemModel*` that is a `RemoteModel` — standard `QAbstractItemModel` API works.
4. Texture image transfer: `TransferImage` (common/transferimage.h) over the wire, arrives via `RemoteViewInterface`. Need to trace the exact signal path for texture grabs. Still open.
## Verification status (as of this revision)
- ✅ **Step 1 done**: GammaRay 3.4.0 built + installed to `install-prefix/`. Protocol version 38.
- ✅ **Minimal client built & run**: `minimal-client/` links `gammaray_client` + `gammaray_common`, connects to a live probe, receives `ready()`, fetches `QuickSceneGraphModel` / `QuickWindowModel` via `ObjectBroker` — clean exit. Proves the client ABI + protocol path is viable for the bridge.
- ✅ **qtmcp FetchContent viable**: `Qt6::McpServer`/`Qt6::McpCommon` build & link from a `FetchContent_MakeAvailable()` call. No separate clone/install.
- ✅ **Bridge scaffold built & working end-to-end** (`bridge/`): `qml-sg-mcp-bridge` links GammaRay client + qtmcp, runs as a stdio MCP server. Smoke test (`/tmp/opencode/smoke_test.py`) against a live probe confirms:
-`listScenegraphNodes` (round 2, after 1s sleep) → full QSGNode tree with 11 GeometryNodes ✅
- ✅ **Connection model revised**: bridge no longer hard-requires `--connect` at startup. Lazy connect + `connectProbe` MCP tool + auto-reconnect on dropped session. See "Connection model (decided)" in Step 7. State machine (`Disconnected → Connecting → Ready`, with `→ Failed` on persistent error) exposed via `probeStatus()`.
### Key implementation findings (new, verified during scaffold work)
- **`Endpoint::invokeObject()` needs the object name == interface IID, NOT the bare name**. `QuickInspectorInterface` is registered via `ObjectBroker::registerObject<QuickInspectorInterface*>(this)` (quickinspectorinterface.cpp:59), which derives the name from `qobject_interface_iid` = `"com.kdab.GammaRay.QuickInspectorInterface/1.0"`. The bare `"com.kdab.GammaRay.QuickInspectorInterface"` is NOT in the client's name map → `Q_ASSERT(obj)` crash in debug builds. Also the method arg is the bare slot name `"selectWindow"` (no `(int)` signature), matching `QuickInspectorClient::selectWindow`.
- **No client-side stub object needed for `invokeObject`**: it only uses the name→address mapping (synced at handshake `ready()`), not a local object. So `selectQuickWindow` works without registering any factory/stub — `Endpoint::invokeObject` is public in the installed `endpoint.h`. (This resolves open question #1's "approach (b)" more cleanly than expected.)
- **RemoteModel is lazily fetched** (client/remotemodel.cpp): `data()`, `rowCount()`, `hasChildren()` return placeholders ("Loading..." / 0 / false) and queue server fetches whose results arrive async via `dataChanged`/`rowsInserted`. So a tool that reads a model right after `ready()` gets stale/empty data. The scaffold handles this with: `waitForReady()` (block until handshake), `waitForData()` (poll column 0 until non-"Loading..."), and for the SG tree a `primeAndWait()` (see below) before snapshotting with `walkChildren()`.
- **`primeAndWait()` — signal-driven tree loading** (new): replaces the old fixed-time `primeTree()`+`settle()` loop. Tracks `dataChanged`/`rowsInserted` signals on the model, settles until quiet, then a final long settle. Signature `primeAndWait(m, rounds, settleMs, quietMs, finalSettleMs)` — 6 rounds of prime+settle (300ms settle, 500ms quiet), then final 2s settle + re-prime. Fixes "Loading..." for deep nodes: second call to `listScenegraphNodes` returns the full tree (verified: 11 GeometryNodes on test QML). First call may still show "Loading..." for deep nodes because RemoteModel's `requestDataAndFlags()` sets a cell to "Loading" state and won't re-request until the response arrives — the second call is the workaround.
- **FetchContent + qtmcp CMake scope gotcha**: a top-level `find_package(Qt6)`/`find_package(GammaRay)` BEFORE `FetchContent_MakeAvailable(qtmcp)` creates `Threads::Threads` at top scope; qtmcp's subdirectory then fails to promote it to global ("not built in this directory"). Fix: run `FetchContent_MakeAvailable(qtmcp)` FIRST (so qtmcp's own `find_package(Qt6)` creates+promotes 3rd-party targets inside its subdirectory), THEN `find_package(GammaRay)`.
- **Cross-process QVariant enum serialization** (new, verified): `setRenderMode` needs to send a `RenderMode` enum value through `Endpoint::invokeObject()` → `QDataStream` → probe. The bridge defines a minimal `RenderMode` enum in `quickinspector_types.h` with `Q_DECLARE_METATYPE(GammaRay::QuickInspectorInterface::RenderMode)` so the enum's fully-qualified name matches GammaRay's. Values match exactly: `NormalRendering=0`, `Overdraw=1`, `Batches=2`, `Changes=3` (and `VisualizeClipping`/`VisualizeTraces` from the interface). Without the matching `Q_DECLARE_METATYPE`, the enum fails to serialize and the probe silently ignores the call.
- **`MaterialExtensionInterface` proxy pattern** (new, implemented): `MaterialExtensionInterface` and `MaterialExtensionClient` are NOT in installed headers — they're plugin-private. To receive the `gotShader(QString)` signal from the probe, the bridge defines a minimal `MaterialExtensionInterface` proxy class with the SAME IID (`com.kdab.GammaRay.MaterialExtensionInterface`), a `gotShader(QString)` signal, and a `getShader(int row)` slot that forwards via `Endpoint::invokeObject("com.kdab.GammaRay.QuickSceneGraph.material", "getShader", {row})`. The concrete `MaterialExtensionProxy` self-registers via `ObjectBroker::registerObject(name, this)` and the factory is registered via `registerClientObjectFactoryCallback<MaterialExtensionInterface*>()` in `initOnce()`. The `material_interface.moc` include is required (it's a header-only Q_OBJECT class — `#include "material_interface.moc"` at the bottom of the .cpp).
- **Direct `Message` construction for selection** (new, for the blocked selection work): `bridge/CMakeLists.txt` adds `/home/blumia/Sources/GammaRay/common` to `target_include_directories` so the bridge can include `<message.h>` and `<protocol.h>` directly from the GammaRay source tree. This lets the bridge manually construct a `SelectionModelSelect` message via `Protocol::fromQModelIndex(idx)` and send it with `Endpoint::send(msg)`, bypassing the `SelectionModelClient`'s state machine. `Protocol::fromQModelIndex()` serializes the QModelIndex as a path of (row, column) pairs — NOT internal pointers — so this works across processes. (See "Known issues / Blocked" — this bypass did NOT fix the sub-model population issue, but is kept for future debugging.)
- **Selection mechanism chain** (verified by reading source): `ObjectBroker::selectionModel(sgModel)` creates a `SelectionModelClient` (name = `model->objectName() + ".selection"`), which syncs to the probe's `SelectionModelServer`. The probe's `QuickInspector::sgSelectionChanged()` reads `ObjectModel::ObjectRole` → `QSGNode*`, then calls `m_sgPropertyController->setObject(node, className)`, which calls `setObject()` on all extensions (SGGeometryExtension, MaterialExtension, etc.) which populate the sub-models. `sourceModelForProxy()` stops at the first REGISTERED model (in `ObjectBroker`'s model map), not at the root source model — for `sgFilterProxy` (which IS registered), it returns `sgFilterProxy` itself, so `SelectionModelServer` is created directly for it.
1.**Unblock sub-model population** — the single highest-priority issue. Try (in order of likelihood):
1.**Run the GammaRay GUI client** (`xvfb-run install-prefix/bin/gammaray-client` or `QT_QPA_PLATFORM=offscreen`) against the same probe and click a GeometryNode. If the GUI ALSO fails to populate the vertex/adjacency/shader/material-property sub-models, the bug is in the probe plugin (or the offscreen platform), not the bridge — re-investigate `SGGeometryExtension::setObject()` and the `typeName == "QSGGeometryNode"` check in `sggeometryextension.cpp`. If the GUI WORKS, the bug is in our selection-sync path.
2.**Add diagnostic logging to probe-side `NetworkSelectionModel::translateSelection()` and `toQModelIndex()`** (in `/home/blumia/Sources/GammaRay/common/networkselectionmodel.cpp` and `common/protocol.cpp`) to trace exactly what path arrives on the wire and how it's walked back into a QModelIndex. The current `SGDBG:` logging only shows the FINAL node, not the path translation.
3.**Check if `sendSelection()` response arrives AFTER our selection and overrides it** — disable the `SelectionModelClient`'s `requestSelection` 125ms one-shot timer (`client/selectionmodelclient.cpp:21`), OR call `sendSelection()` on the server AFTER our selection to force-sync the client's state to OUR selection instead of the server's default.
4.**Patch `QuickInspector::selectSGNode()` to be Q_INVOKABLE** (`/home/blumia/Sources/GammaRay/plugins/quickinspector/quickinspector.cpp:474`). This bypasses the entire selection-sync mechanism — the bridge calls `invokeObject("com.kdab.GammaRay.QuickInspectorInterface/1.0", "selectSGNode", {QVariant::fromValue<void*>(nodePtr)})` directly. Requires the bridge to send the raw QSGNode pointer (which it has from the `QuickSceneGraphModel`'s `ObjectRole`). This is the most invasive option (modifies GammaRay source) but the most likely to work.
2.**Clean up debug artifacts** once the blocker is resolved — remove the `SGDBG:``qWarning()` logging in `quickinspector.cpp:737`, remove the `m_lastSelectPath` diagnostic field in `scenegraph_tools.{h,cpp}`.
3.**Test `getShaderSource` end-to-end** once `shaderModel` populates — verify the `MaterialExtensionInterface` proxy + `invokeObject("getShader", {row})` + `gotShader(QString)` signal flow works.
4.**Implement `grabTexture(objectId)`** — `TextureExtension` async image grab via `RemoteViewInterface``TransferImage`. Follows the same `MaterialExtensionInterface` proxy pattern: a thin `TextureExtensionInterface` proxy with matching IID. Depends on selection working first to populate the texture list.
5.**Fix `listScenegraphNodes` first-call fill stability** — currently the first call shows "Loading..." for deep nodes and the second call returns the full tree. Either: (a) make `primeAndWait()` smarter (detect when a "Loading..." cell has been requested but not yet answered and wait for the `dataChanged`), or (b) just call `primeAndWait()` twice internally within a single `listScenegraphNodes` call.
6.**Pin qtmcp `GIT_TAG`** to a specific commit/tag in `bridge/CMakeLists.txt` for reproducibility (currently tracks `main`).