init commit

This commit is contained in:
2026-07-06 20:32:47 +08:00
commit 431ca6e7b1
11 changed files with 1394 additions and 0 deletions

66
bridge/CMakeLists.txt Normal file
View File

@@ -0,0 +1,66 @@
# QML SceneGraph MCP Bridge
# SPDX-License-Identifier: GPL-2.0-or-later
#
# Links GammaRay client libs (GPL-2.0-or-later) => this bridge must be
# GPL-compatible. qtmcp is consumed under its GPL-2.0-only option (compatible).
cmake_minimum_required(VERSION 3.16)
project(QmlSceneGraphMcpBridge LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20) # qtmcp requires C++20
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
# --- qtmcp via FetchContent FIRST ---
# qtmcp's find_package(Qt6) (inside the subdirectory) creates 3rd-party targets
# like Threads::Threads and promotes them to global THERE. If a prior top-level
# find_package(Qt6)/find_package(GammaRay) already created Threads::Threads at top
# scope, the subdirectory promotion fails ("not built in this directory"). So
# FetchContent runs before any Qt6/GammaRay find at this level.
include(FetchContent)
FetchContent_Declare(
qtmcp
GIT_REPOSITORY https://github.com/signal-slot/qtmcp.git
GIT_TAG main # TODO: pin to a released tag/commit for reproducibility
GIT_SHALLOW TRUE
)
# Qt-prefixed vars (NOT BUILD_EXAMPLES) — verified to suppress example/test build.
set(QT_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
set(QT_BUILD_TESTS OFF CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(qtmcp)
# --- GammaRay (built & installed in ../install-prefix by Step 1) ---
# Pass -DCMAKE_PREFIX_PATH=$(pwd)/../install-prefix at configure.
find_package(GammaRay REQUIRED)
# --- Qt6 components for the bridge target (Qt6 already found globally by qtmcp) ---
find_package(Qt6 COMPONENTS Core Gui Widgets Network REQUIRED)
add_executable(qml-sg-mcp-bridge
src/main.cpp
src/gammaray_session.cpp
src/gammaray_session.h
src/scenegraph_tools.cpp
src/scenegraph_tools.h
)
target_link_libraries(qml-sg-mcp-bridge PRIVATE
gammaray_client # VERIFIED: no GammaRay:: namespace
gammaray_common
Qt6::Core
Qt6::Gui
Qt6::Widgets # REQUIRED: QApplication for RemoteModel::style()
Qt6::Network
Qt6::McpServer # qtmcp, provides Qt::McpServer alias
Qt6::McpCommon
)
# Runtime: qtmcp builds SHARED libs + the stdio backend plugin into the build
# tree. Set RPATH so the bridge finds libQt6Mcp*.so, and set QT_PLUGIN_PATH at
# run time (see run.sh) so Qt loads mcpserverbackend/libqmcpserverstdio.so.
# Also add GammaRay's install lib dir for libgammaray_*.so.
set(_GR_LIBDIR "$<TARGET_PROPERTY:gammaray_client,IMPORTED_LOCATION>")
set_target_properties(qml-sg-mcp-bridge PROPERTIES
BUILD_RPATH "$<TARGET_FILE_DIR:Qt6::McpServer>;${CMAKE_SOURCE_DIR}/../install-prefix/lib"
INSTALL_RPATH "$ORIGIN/../lib"
)

13
bridge/run.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/sh
# Run the QML SceneGraph MCP bridge.
# Sets up LD_LIBRARY_PATH (GammaRay + qtmcp libs) and QT_PLUGIN_PATH (qtmcp stdio
# backend plugin) so the bridge can find everything at runtime.
# SPDX-License-Identifier: GPL-2.0-or-later
set -e
HERE="$(cd "$(dirname "$0")" && pwd)"
ROOT="$HERE/.."
BLD="$HERE/build"
export LD_LIBRARY_PATH="$ROOT/install-prefix/lib:$BLD/lib/x86_64-linux-gnu:${LD_LIBRARY_PATH:-}"
export QT_PLUGIN_PATH="$BLD/lib/x86_64-linux-gnu/qt6/plugins:${QT_PLUGIN_PATH:-}"
export QT_QPA_PLATFORM=offscreen
exec "$BLD/qml-sg-mcp-bridge" "$@"

View File

@@ -0,0 +1,157 @@
/*
gammaray_session.cpp
SPDX-FileCopyrightText: 2026 The GammaRay MCP Bridge Authors
SPDX-License-Identifier: GPL-2.0-or-later
*/
#include "gammaray_session.h"
#include <client/clientconnectionmanager.h>
#include <common/objectbroker.h>
#include <QCoreApplication>
#include <QEventLoop>
#include <QTimer>
GammaRaySession::GammaRaySession(QObject *parent)
: QObject(parent)
{
// Parent the connection manager to the app so it outlives this object if needed;
// pass showSplash=false — no GUI.
m_conMan = new GammaRay::ClientConnectionManager(qApp, false);
QObject::connect(m_conMan, &GammaRay::ClientConnectionManager::ready, [this]() {
m_lastError.clear();
setState(State::Ready);
// Pre-fetch the remote models we expose as tools. ObjectBroker::model()
// lazily creates a RemoteModel which then syncs asynchronously from the
// probe (~1-2s for the first rows). Requesting them now at ready() means
// that by the time an MCP client calls a tool (after initialize +
// tools/list + its own think time) the model rows have arrived. Tools
// read rowCount()/data() directly on these cached instances.
GammaRay::ObjectBroker::model(QStringLiteral("com.kdab.GammaRay.QuickWindowModel"));
GammaRay::ObjectBroker::model(QStringLiteral("com.kdab.GammaRay.QuickSceneGraphModel"));
emit ready();
});
QObject::connect(m_conMan, &GammaRay::ClientConnectionManager::disconnected, [this]() {
// A live session dropped (probe killed / target crashed). The manager
// does NOT auto-retry mid-session drops (only initial-handshake
// transient failures), so do it here with a 1s backoff. Guard against
// re-entrancy: scheduleReconnect() sets m_reconnectScheduled so we only
// arm one timer at a time. If the user calls disconnectFromHost()
// (which clears m_serverUrl's intent) we won't reconnect.
setState(State::Disconnected);
emit disconnected();
scheduleReconnect();
});
QObject::connect(m_conMan, &GammaRay::ClientConnectionManager::persistentConnectionError,
[this](const QString &msg) {
m_lastError = msg;
setState(State::Failed);
emit connectionError(msg);
// Don't auto-retry: GammaRay's manager already retried
// every 1s for 60s before firing this. The user should
// call connectProbe() again once the probe is back.
});
}
GammaRaySession::~GammaRaySession() = default;
void GammaRaySession::initOnce()
{
GammaRay::ClientConnectionManager::init();
}
void GammaRaySession::connectToHost(const QUrl &url)
{
m_serverUrl = url;
m_lastError.clear();
m_reconnectScheduled = false; // any in-flight timer is now redundant
setState(State::Connecting);
m_conMan->connectToHost(url);
}
void GammaRaySession::disconnectFromHost()
{
m_reconnectScheduled = false;
// Clear the URL so auto-reconnect won't fire after an explicit disconnect.
// (lastUrl() returns empty afterwards — connectProbe() is required to
// re-establish, which matches user intent for "I want this bridge off".)
m_serverUrl = QUrl();
m_conMan->disconnectFromHost();
setState(State::Disconnected);
}
bool GammaRaySession::ensureConnected(int timeoutMs)
{
if (m_state == State::Ready)
return true;
if (!m_serverUrl.isValid())
return false; // caller must invoke connectProbe() first
if (m_state == State::Disconnected || m_state == State::Failed)
connectToHost(m_serverUrl); // kick a fresh attempt
// Now in Connecting (or Ready). Wait for the handshake up to timeoutMs.
QEventLoop loop;
const QMetaObject::Connection readyConn =
QObject::connect(this, &GammaRaySession::ready, &loop, &QEventLoop::quit);
const QMetaObject::Connection errConn =
QObject::connect(this, &GammaRaySession::connectionError, &loop, &QEventLoop::quit);
QTimer::singleShot(timeoutMs, &loop, &QEventLoop::quit);
loop.exec();
QObject::disconnect(readyConn);
QObject::disconnect(errConn);
return m_state == State::Ready;
}
void GammaRaySession::waitForReady(int timeoutMs)
{
if (m_state == State::Ready)
return;
QEventLoop loop;
QObject::connect(this, &GammaRaySession::ready, &loop, &QEventLoop::quit);
QObject::connect(this, &GammaRaySession::connectionError, &loop, &QEventLoop::quit);
QTimer::singleShot(timeoutMs, &loop, &QEventLoop::quit);
loop.exec();
}
QAbstractItemModel *GammaRaySession::model(const QString &name) const
{
if (m_state != State::Ready)
return nullptr;
return GammaRay::ObjectBroker::model(name);
}
QString GammaRaySession::stateString(State s)
{
switch (s) {
case State::Disconnected: return QStringLiteral("disconnected");
case State::Connecting: return QStringLiteral("connecting");
case State::Ready: return QStringLiteral("ready");
case State::Failed: return QStringLiteral("failed");
}
return QStringLiteral("unknown");
}
void GammaRaySession::setState(State s)
{
if (m_state == s)
return;
m_state = s;
emit stateChanged(s);
}
void GammaRaySession::scheduleReconnect()
{
if (m_reconnectScheduled)
return;
if (!m_serverUrl.isValid())
return; // user explicitly disconnected
m_reconnectScheduled = true;
QTimer::singleShot(1000, this, [this]() {
m_reconnectScheduled = false;
if (m_state == State::Disconnected && m_serverUrl.isValid())
connectToHost(m_serverUrl);
});
}

View File

@@ -0,0 +1,109 @@
/*
gammaray_session.h — wraps GammaRay ClientConnectionManager + ObjectBroker.
SPDX-FileCopyrightText: 2026 The GammaRay MCP Bridge Authors
SPDX-License-Identifier: GPL-2.0-or-later
The bridge does NOT load the per-tool GUI client plugins (gammaray_quickinspector
etc.) — those ship only client-side class headers inside the GammaRay SOURCE tree,
not in the installed headers (see PLAN.md open question #1). This class only uses
the installed client/common APIs: ClientConnectionManager + ObjectBroker.
Connection lifecycle
--------------------
GammaRay's ClientConnectionManager already retries "transient" connection
failures (host not yet up) every 1s for 60s before giving up and emitting
persistentConnectionError. So an MCP client that starts the bridge before the
probe is up has ~60s to bring the probe online.
This class exposes an explicit state machine on top:
Disconnected → Connecting → Ready
↘ Failed (persistent error; user must call connectToHost again)
and adds auto-reconnect on `disconnected` (mid-session drops) since the manager
only auto-retries the INITIAL handshake, not a dropped connection.
Tools call `ensureConnected(timeoutMs)` instead of `waitForReady()` so that a
stale/failed session is transparently re-brought-up using the last known URL.
*/
#ifndef GAMMARAYSESSION_H
#define GAMMARAYSESSION_H
#include <QObject>
#include <QString>
#include <QUrl>
QT_BEGIN_NAMESPACE
class QAbstractItemModel;
QT_END_NAMESPACE
namespace GammaRay {
class ClientConnectionManager;
}
class GammaRaySession : public QObject
{
Q_OBJECT
public:
enum class State {
Disconnected, // no connection, no recent failure (e.g. just started, or user called disconnect)
Connecting, // connectToHost() called, handshake in progress (may still be in 60s retry window)
Ready, // handshake complete, models available
Failed, // persistentConnectionError fired — user must call connectToHost() again
};
explicit GammaRaySession(QObject *parent = nullptr);
~GammaRaySession() override;
// One-time init of stream operators + factory callbacks. Call once per process.
static void initOnce();
// Connect to a GammaRay probe. Async — emits ready() on success. Stores the
// URL so ensureConnected()/auto-reconnect can retry later. Safe to call
// repeatedly (each call resets state to Connecting and clears lastError).
void connectToHost(const QUrl &url);
// Drop the current connection (if any) and forget auto-reconnect intent.
// `url` is retained so ensureConnected() can still bring it back up.
void disconnectFromHost();
// Block (spinning a local event loop) up to timeoutMs for the probe
// connection handshake to complete. Returns true if ready. If not ready and
// a lastUrl is known, also (re)issues connectToHost() first so a stale
// session is transparently recovered. Returns false if still not ready.
bool ensureConnected(int timeoutMs);
// Back-compat: was the entry point for tools; now a thin wrapper that just
// waits (no auto-reconnect). Prefer ensureConnected() in new code.
void waitForReady(int timeoutMs);
bool isReady() const { return m_state == State::Ready; }
State state() const { return m_state; }
static QString stateString(State s);
QString stateString() const { return stateString(m_state); }
QUrl lastUrl() const { return m_serverUrl; }
QString lastError() const { return m_lastError; }
// Retrieve a remote model by name (e.g. "com.kdab.GammaRay.QuickSceneGraphModel").
// Returns nullptr if not ready / model not registered. Do not cache across
// disconnect/reconnect.
QAbstractItemModel *model(const QString &name) const;
signals:
void ready();
void disconnected();
void connectionError(const QString &msg);
void stateChanged(GammaRaySession::State newState);
private:
void setState(State s);
void scheduleReconnect();
GammaRay::ClientConnectionManager *m_conMan = nullptr;
State m_state = State::Disconnected;
QUrl m_serverUrl;
QString m_lastError;
bool m_reconnectScheduled = false;
};
#endif // GAMMARAYSESSION_H

108
bridge/src/main.cpp Normal file
View File

@@ -0,0 +1,108 @@
/*
qml-sg-mcp-bridge — MCP server bridging QML SceneGraph introspection.
SPDX-FileCopyrightText: 2026 The GammaRay MCP Bridge Authors
SPDX-License-Identifier: GPL-2.0-or-later
The bridge is a GammaRay CLIENT peer: it connects to a probe injected into a
target Qt/QML app and exposes the resulting introspection data as MCP tools
(JSON-RPC over stdio) via qtmcp. See PLAN.md for the architecture.
IMPORTANT: must use QApplication (not QCoreApplication) — GammaRay's
RemoteModel ctor calls QApplication::style()->sizeFromContents(). With
QCoreApplication the bridge segfaults the moment ObjectBroker::model() is
first called after ClientConnectionManager::ready().
*/
#include "gammaray_session.h"
#include "scenegraph_tools.h"
#include <common/endpoint.h>
#include <QApplication>
#include <QCommandLineOption>
#include <QCommandLineParser>
#include <QLoggingCategory>
#include <QtMcpServer/QMcpServer>
int main(int argc, char **argv)
{
// stdio is the MCP transport — keep stdout clean. Qt log messages go to
// stderr by default, but suppress debug noise to be safe.
qSetMessagePattern(QStringLiteral("[%{type}] %{message}"));
QApplication app(argc, argv);
app.setApplicationName(QStringLiteral("qml-sg-mcp-bridge"));
app.setApplicationVersion(QStringLiteral("0.1.0"));
app.setOrganizationName(QStringLiteral("KDAB"));
QCommandLineParser parser;
parser.setApplicationDescription(QStringLiteral("QML SceneGraph MCP Bridge"));
parser.addHelpOption();
parser.addVersionOption();
QCommandLineOption connectOption(
QStringList() << QStringLiteral("connect"),
QStringLiteral("GammaRay probe URL, e.g. tcp://127.0.0.1:11732"),
QStringLiteral("url"));
parser.addOption(connectOption);
QCommandLineOption envFallback(
QStringList() << QStringLiteral("env-url"),
QStringLiteral("read probe URL from GAMMARAY_PROBE_URL if --connect is not given"));
Q_UNUSED(envFallback);
parser.process(app);
QUrl probeUrl;
if (parser.isSet(connectOption)) {
probeUrl = QUrl::fromUserInput(parser.value(connectOption));
} else {
const QByteArray env = qgetenv("GAMMARAY_PROBE_URL");
if (!env.isEmpty())
probeUrl = QUrl::fromUserInput(QString::fromUtf8(env));
}
// One-time init of GammaRay stream operators + factory callbacks.
GammaRaySession::initOnce();
GammaRaySession session(&app);
QMcpServer server(QStringLiteral("stdio"));
server.setInstructions(QStringLiteral(
"QML SceneGraph introspection via GammaRay. Connects to a GammaRay probe "
"injected into a Qt/QML application and exposes the scene graph, items, "
"geometry, materials and shaders as tools."));
SceneGraphTools tools(&session, &server);
server.registerToolSet(&tools, {
// Connection management. The bridge no longer hard-requires --connect:
// a client calls connectProbe() to (re)establish a probe connection,
// and any introspection tool will also auto-reconnect if a URL was
// previously established. connectProbeDefault() is the zero-arg entry
// point for the common 127.0.0.1:11732 case.
{ QStringLiteral("connectProbe"), QStringLiteral("Connect to a GammaRay probe. host defaults to 127.0.0.1; pass port=0 to use the default 11732.") },
{ QStringLiteral("connectProbe/host"), QStringLiteral("Probe host (default 127.0.0.1)") },
{ QStringLiteral("connectProbe/port"), QStringLiteral("Probe port (default 11732; pass 0 to use default)") },
{ QStringLiteral("connectProbeDefault"), QStringLiteral("Connect to a GammaRay probe at 127.0.0.1:11732 (convenience for connectProbe with no args)") },
{ QStringLiteral("disconnectProbe"), QStringLiteral("Drop the current probe connection and forget the URL") },
{ QStringLiteral("probeStatus"), QStringLiteral("Report current probe connection state, last URL and last error") },
// Introspection
{ QStringLiteral("listQuickWindows"), QStringLiteral("List QQuickWindows in the target app") },
{ QStringLiteral("selectQuickWindow"), QStringLiteral("Select a Quick window (by index into listQuickWindows) so the scene graph is introspected for it") },
{ QStringLiteral("selectQuickWindow/index"), QStringLiteral("0-based index of the window in listQuickWindows") },
{ QStringLiteral("listScenegraphNodes"), QStringLiteral("List the QSGNode tree of the selected Quick window") },
});
QObject::connect(&server, &QMcpServer::finished, &app, &QCoreApplication::quit);
if (probeUrl.isValid()) {
// Best-effort initial connection. If the probe isn't up yet, GammaRay's
// ClientConnectionManager retries every 1s for 60s; tools will also
// transparently retry via ensureConnected() once a URL is known.
session.connectToHost(probeUrl);
}
server.start();
return app.exec();
}

View File

@@ -0,0 +1,254 @@
/*
scenegraph_tools.cpp
SPDX-FileCopyrightText: 2026 The GammaRay MCP Bridge Authors
SPDX-License-Identifier: GPL-2.0-or-later
*/
#include "scenegraph_tools.h"
#include "gammaray_session.h"
#include <common/endpoint.h>
#include <QAbstractItemModel>
#include <QCoreApplication>
#include <QEventLoop>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QTimer>
#include <QUrl>
#include <QVariantList>
// Model registration names (from plugins/quickinspector/quickinspector.cpp).
static const char *kQuickWindowModel = "com.kdab.GammaRay.QuickWindowModel";
static const char *kQuickSceneGraphModel = "com.kdab.GammaRay.QuickSceneGraphModel";
// RemoteModel populates asynchronously from the probe and fetches cell data
// lazily: data() returns a "Loading..." placeholder and queues a server fetch;
// the real value arrives later via dataChanged. So we must (a) trigger the
// fetches by calling data(), and (b) wait for the values to settle. We poll in
// a local event loop until the first column display is non-empty AND not the
// "Loading..." placeholder, or until the total budget elapses.
static void waitForData(const QAbstractItemModel *m, int column, int timeoutMs)
{
if (!m)
return;
QEventLoop loop;
int waited = 0;
const int step = 150;
while (waited < timeoutMs) {
if (m->rowCount() > 0) {
// Trigger/refresh the cell fetch for the first row's target column.
const auto v = m->data(m->index(0, column), Qt::DisplayRole).toString();
if (!v.isEmpty() && v != QLatin1String("Loading..."))
return;
}
QTimer::singleShot(step, &loop, &QEventLoop::quit);
loop.exec();
waited += step;
}
}
static QJsonObject errorJson(const QString &msg)
{
return { { QStringLiteral("error"), msg } };
}
// Recursive tree walker for tree-shaped remote models.
static QJsonArray walkChildren(const QAbstractItemModel *m, const QModelIndex &parent, int depth)
{
QJsonArray out;
if (depth <= 0)
return out;
for (int r = 0; r < m->rowCount(parent); ++r) {
const QModelIndex idx = m->index(r, 0, parent);
const QString address = m->data(idx, Qt::DisplayRole).toString();
const QModelIndex typeIdx = m->index(r, 1, parent);
const QString type = m->data(typeIdx, Qt::DisplayRole).toString();
QJsonObject node;
node.insert(QStringLiteral("address"), address);
node.insert(QStringLiteral("type"), type);
if (m->hasChildren(idx))
node.insert(QStringLiteral("children"), walkChildren(m, idx, depth - 1));
out.append(node);
}
return out;
}
// RemoteModel fetches cell data AND child row counts lazily from the probe:
// data()/rowCount()/hasChildren() trigger server fetches and return a
// placeholder ("Loading..." / 0 / false) until the response arrives. So to get
// a populated tree we must (1) prime: walk the model touching every cell and
// rowCount() to queue all fetches, (2) let the event loop run so responses
// arrive, (3) read. We repeat prime+settle a few rounds so deeper levels
// become reachable as their parents' row counts arrive.
static void primeTree(const QAbstractItemModel *m, const QModelIndex &parent, int depth)
{
if (depth <= 0)
return;
for (int r = 0; r < m->rowCount(parent); ++r) {
const QModelIndex idx = m->index(r, 0, parent);
m->data(idx, Qt::DisplayRole);
m->data(m->index(r, 1, parent), Qt::DisplayRole);
if (m->hasChildren(idx))
primeTree(m, idx, depth - 1);
}
}
static void settle(QEventLoop &loop, int ms)
{
QTimer::singleShot(ms, &loop, &QEventLoop::quit);
loop.exec();
}
SceneGraphTools::SceneGraphTools(GammaRaySession *session, QObject *parent)
: QObject(parent)
, m_session(session)
{
}
QString SceneGraphTools::ensureSession() const
{
if (!m_session)
return QString::fromUtf8(QJsonDocument(errorJson(
QStringLiteral("no session"))).toJson(QJsonDocument::Compact));
// First, opportunistically bring up a connection if we have a URL.
if (m_session->lastUrl().isValid()) {
m_session->ensureConnected(6000);
}
if (m_session->isReady())
return {};
// No URL known OR reconnect failed: tell the user to call connectProbe.
QJsonObject err = errorJson(
m_session->lastUrl().isValid()
? QStringLiteral("not connected to probe (last error: %1; call connectProbe to retry)")
.arg(m_session->lastError().isEmpty() ? QStringLiteral("timeout") : m_session->lastError())
: QStringLiteral("no probe URL configured — call connectProbe(host, port) first"));
err.insert(QStringLiteral("state"), m_session->stateString());
return QString::fromUtf8(QJsonDocument(err).toJson(QJsonDocument::Compact));
}
QString SceneGraphTools::connectProbe(const QString &host, int port) const
{
if (!m_session)
return QString::fromUtf8(QJsonDocument(errorJson(
QStringLiteral("no session"))).toJson(QJsonDocument::Compact));
const QString h = host.isEmpty() ? QStringLiteral("127.0.0.1") : host;
const int p = (port <= 0) ? 11732 : port;
const QUrl url(QStringLiteral("tcp://%1:%2").arg(h).arg(p));
m_session->connectToHost(url);
m_session->waitForReady(6000);
QJsonObject out = {
{ QStringLiteral("connected"), m_session->isReady() },
{ QStringLiteral("state"), m_session->stateString() },
{ QStringLiteral("url"), url.toString() },
};
if (!m_session->isReady())
out.insert(QStringLiteral("error"),
m_session->lastError().isEmpty()
? QStringLiteral("timeout connecting — is the probe listening on %1?").arg(url.toString())
: m_session->lastError());
return QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Compact));
}
QString SceneGraphTools::connectProbeDefault() const
{
return connectProbe(QStringLiteral("127.0.0.1"), 11732);
}
QString SceneGraphTools::disconnectProbe() const
{
if (!m_session)
return QString::fromUtf8(QJsonDocument(errorJson(
QStringLiteral("no session"))).toJson(QJsonDocument::Compact));
m_session->disconnectFromHost();
return probeStatus();
}
QString SceneGraphTools::probeStatus() const
{
if (!m_session)
return QString::fromUtf8(QJsonDocument(errorJson(
QStringLiteral("no session"))).toJson(QJsonDocument::Compact));
QJsonObject out = {
{ QStringLiteral("state"), m_session->stateString() },
{ QStringLiteral("ready"), m_session->isReady() },
{ QStringLiteral("url"), m_session->lastUrl().toString() },
};
if (!m_session->lastError().isEmpty())
out.insert(QStringLiteral("error"), m_session->lastError());
return QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Compact));
}
QString SceneGraphTools::listQuickWindows() const
{
if (const QString e = ensureSession(); !e.isEmpty())
return e;
auto *m = m_session->model(QString::fromUtf8(kQuickWindowModel));
if (!m)
return QString::fromUtf8(QJsonDocument(errorJson(
QStringLiteral("QuickWindowModel not available (no Quick app attached?)"))).toJson(QJsonDocument::Compact));
waitForData(m, 0, 3000);
QJsonArray out;
for (int r = 0; r < m->rowCount(); ++r) {
const QModelIndex a = m->index(r, 0);
const QModelIndex t = m->index(r, 1);
QJsonObject w;
w.insert(QStringLiteral("address"), m->data(a, Qt::DisplayRole).toString());
w.insert(QStringLiteral("type"), m->data(t, Qt::DisplayRole).toString());
out.append(w);
}
return QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Compact));
}
QString SceneGraphTools::selectQuickWindow(int index) const
{
if (const QString e = ensureSession(); !e.isEmpty())
return e;
// The QuickInspectorInterface lives on the probe side. Its header isn't in
// the installed client headers, but Endpoint::invokeObject() can forward a
// method call to a remote object by name+address. The name→address mapping
// is synced during the handshake (ready()), so once connected we can call
// it directly — no client-side stub needed (invokeObject only uses the
// address, not a local object). The object name is the interface IID
// (quickinspectorinterface.cpp:59 registers via the IID), and the method
// name is the bare slot name "selectWindow" (matches QuickInspectorClient).
auto *ep = GammaRay::Endpoint::instance();
if (!ep)
return QString::fromUtf8(QJsonDocument(errorJson(
QStringLiteral("no GammaRay endpoint"))).toJson(QJsonDocument::Compact));
ep->invokeObject(QStringLiteral("com.kdab.GammaRay.QuickInspectorInterface/1.0"),
"selectWindow", QVariantList{index});
return QString::fromUtf8(QJsonDocument(QJsonObject{
{ QStringLiteral("status"), QStringLiteral("selected") },
{ QStringLiteral("index"), index },
}).toJson(QJsonDocument::Compact));
}
QString SceneGraphTools::listScenegraphNodes() const
{
if (const QString e = ensureSession(); !e.isEmpty())
return e;
auto *m = m_session->model(QString::fromUtf8(kQuickSceneGraphModel));
if (!m)
return QString::fromUtf8(QJsonDocument(errorJson(
QStringLiteral("QuickSceneGraphModel not available (no Quick app attached?)"))).toJson(QJsonDocument::Compact));
// Prime + settle across a few rounds so lazily-fetched cell data and child
// row counts have time to arrive before we snapshot the tree.
QEventLoop loop;
for (int round = 0; round < 4; ++round) {
primeTree(m, QModelIndex(), 16);
settle(loop, 600);
}
// Depth cap to keep responses bounded for large scenes.
const QJsonArray tree = walkChildren(m, QModelIndex(), 16);
return QString::fromUtf8(QJsonDocument(tree).toJson(QJsonDocument::Compact));
}

View File

@@ -0,0 +1,74 @@
/*
scenegraph_tools.h — Q_INVOKABLE methods exposed as MCP tools via qtmcp.
SPDX-FileCopyrightText: 2026 The GammaRay MCP Bridge Authors
SPDX-License-Identifier: GPL-2.0-or-later
Return types are constrained by qtmcp's callTool(): void/bool/QString/QStringList/
QImage (sync) or QFuture<QList<QMcpCallToolResultContent>> (async). Structured
results are returned as JSON-serialized QString. See PLAN.md.
Connection model
----------------
Tools do NOT take the connection for granted. Each one calls
`session->ensureConnected(timeoutMs)` first: if a previous probe URL was
established (via --connect at startup OR a prior connectProbe() call), a stale
session is transparently reconnected. If no URL is known, the tool returns a
JSON error pointing the user at connectProbe().
*/
#ifndef SCENEGRAPH_TOOLS_H
#define SCENEGRAPH_TOOLS_H
#include <QObject>
#include <QString>
class GammaRaySession;
class SceneGraphTools : public QObject
{
Q_OBJECT
public:
explicit SceneGraphTools(GammaRaySession *session, QObject *parent = nullptr);
// --- Connection management ---
// Connect to a GammaRay probe. host defaults to 127.0.0.1, port to 11732
// (GammaRay's default). Blocks up to ~6s for the handshake. Returns JSON:
// {"connected":bool,"state":"ready|connecting|failed|disconnected",
// "url":"tcp://127.0.0.1:11732","error":"<only if failed>"}
// Even on failure the URL is remembered so subsequent tool calls will retry.
Q_INVOKABLE QString connectProbe(const QString &host, int port) const;
// Same with default port (overload not supported by qtmcp; use 0 to mean default).
Q_INVOKABLE QString connectProbeDefault() const;
// Drop the current connection and forget the URL. Subsequent tool calls
// will require connectProbe() again.
Q_INVOKABLE QString disconnectProbe() const;
// Report current connection state. Returns the same JSON shape as
// connectProbe() minus the action.
Q_INVOKABLE QString probeStatus() const;
// --- Introspection ---
// List top-level QQuickWindows. Returns compact JSON: [{"address":..,"type":..}]
Q_INVOKABLE QString listQuickWindows() const;
// Select a Quick window by index (into listQuickWindows) on the probe side.
// This is required before listScenegraphNodes returns anything — the
// QuickSceneGraphModel is built for the *selected* window.
// Uses Endpoint::invokeObject directly since QuickInspectorInterface is not
// in the installed client headers (PLAN.md open question #1).
Q_INVOKABLE QString selectQuickWindow(int index) const;
// Walk the QSGNode tree and return nested JSON:
// [{"address":..,"type":..,"children":[...]}]
Q_INVOKABLE QString listScenegraphNodes() const;
private:
GammaRaySession *m_session;
// Shared helper: ensures a connection (auto-reconnect if a URL is known),
// returns nullptr on success or a JSON error string the caller can return
// verbatim. The JSON always hints at connectProbe() when no URL is known.
QString ensureSession() const;
};
#endif // SCENEGRAPH_TOOLS_H