PyQt Runtime MCP
Runtime inspection and interaction for any PyQt5 QWidget application, in the same spirit as browser DevTools / Playwright — but Qt-native.
Cursor talks to an MCP stdio server. That server talks over localhost TCP to a small bridge running inside the target app. All QWidget work happens on the Qt GUI thread.
This package is reusable. GCS is only one consumer.
Installation
From PyPI (after the package is published):
pip install pyqt-runtime-mcp
From this folder (development):
pip install -e .
The in-app bridge needs PyQt5 (already present in a PyQt app). The Cursor MCP process needs this package — it pulls in the mcp SDK and does not need Qt. Optional extra for the demo app only: pip install "pyqt-runtime-mcp[qt]".
In this repository, adding the pyqt-runtime entry to .cursor/mcp.json is enough. Cursor starts the server the same way as the other project MCP servers. GCS starts the in-app bridge automatically with python main.py — no extra environment variables.
Quick start
-
Run the app (
python main.pyor the demo below). The in-app bridge listens on127.0.0.1:8765. -
Point Cursor at the MCP server (see Cursor configuration). Reload MCP servers if you just added it.
-
Ask:
Inspect the current PyQt application and give me its widget hierarchy.
Demo without GCS (from this folder, with PyQt5 installed):
python examples/demo_app.py
Integration into an existing PyQt5 application
from PyQt5.QtWidgets import QApplication
from pyqt_runtime_mcp import install_pyqt_mcp
app = QApplication(sys.argv)
window = MainWindow()
install_pyqt_mcp(app)
window.show()
sys.exit(app.exec_())
Equivalent:
from pyqt_runtime_mcp import PyQtMCPBridge
bridge = PyQtMCPBridge(app)
bridge.start()
The bridge must be started inside the process that owns QApplication. It will not attach to a random already-running PyQt process.
GCS (this repository)
main.py always starts the localhost bridge. Run the app as usual:
python .\main.py
If the bridge cannot bind, GCS still runs and logs a warning.
Starting the MCP server
After pip install pyqt-runtime-mcp, either command works:
pyqt-runtime-mcp
python -m pyqt_runtime_mcp
From this source tree without installing, use python run_server.py.
If the app is not running, tools return APPLICATION_NOT_CONNECTED instead of hanging.
Cursor configuration
After a PyPI / editable install, use the console script (same Python that ran pip install):
"pyqt-runtime": {
"command": "pyqt-runtime-mcp"
}
Equivalent:
"pyqt-runtime": {
"command": "python",
"args": ["-m", "pyqt_runtime_mcp"]
}
This repository's .cursor/mcp.json still launches run_server.py so GCS works without a prior pip install. Reload MCP servers in Cursor after saving. Start the PyQt app, then ask Cursor to inspect it.
Available MCP tools
Inspection: qt_get_application_info, qt_list_windows, qt_get_widget_tree, qt_find_widgets, qt_get_widget, qt_get_layout, qt_get_geometry, qt_get_ui_snapshot
Visual: qt_capture_window, qt_capture_widget, qt_capture_region, qt_capture_screen_region, qt_capture_sections, qt_visual_snapshot
Interaction: qt_click, qt_double_click, qt_move_mouse, qt_set_focus, qt_type_text, qt_key_press
Semantic: qt_set_text, qt_set_value, qt_set_checked, qt_select_combobox, qt_set_current_page
Window: qt_resize_window, qt_move_window, qt_activate_window, qt_close_window, qt_close_windows (confirm=true required on both close tools)
Style / meta: qt_get_stylesheet, qt_set_stylesheet, qt_list_properties, qt_get_property, qt_set_property
Diagnostics: qt_analyze_layout, qt_list_signals, qt_watch_signal, qt_get_event_log, qt_get_logs, qt_get_exceptions, qt_show_debug_overlay, qt_hide_debug_overlay
There is no execute_python / eval / shell tool.
Runtime logs
qt_get_logs returns one bounded ring buffer holding both Qt messages (captured through
qInstallMessageHandler) and the application's own stdlib logging records (captured with a
handler added to the root logger). Entries are tagged source: "qt" | "python"; filter with
source, level, or contains. Neither capture changes what the application already prints,
and the root logger's level is left untouched — records the app filters out stay filtered out.
Selecting a widget
Every tool that takes widget_id accepts a runtime id (qt://widget/N) or an objectName.
Detaching a view usually clones objectNames into a second window, which makes a bare name
ambiguous; the error then lists each candidate with the window it lives in. Pass
window=<window id or objectName> to scope the lookup to one window's subtree.
qt_find_widgets takes the same window argument.
Closing windows
qt_close_window closes one window and verifies the result: closed is false when a
closeEvent handler refused, and force=true then hides and deletes the widget. Dialogs are
rejected first so an exec_() loop unwinds. qt_close_windows sweeps every visible top-level
window — the way to clean up detached views, popups, and leftover dialogs after a test run.
It protects QMainWindow instances unless include_main=true, and can be narrowed with
windows, class_name, or title_contains. The reply splits results into closed,
failed, skipped, and the remaining visible windows.
Example prompts
Inspect the current PyQt application and give me its widget hierarchy.
Take a screenshot of MainWindow.
Find the widget named telemetryPanel and inspect its geometry.
Resize MainWindow to 1024x600 and identify layout problems.
Find all QLabel widgets whose contents are clipped.
Open page 3 of the main QStackedWidget.
Click the Settings button.
Capture the Settings page.
Capture labeled sections for the status bar, emergency rail, and map area so each can be checked separately.
Inspect the layout and tell me why the bottom controls are outside the visible area.
Section screenshots
qt_capture_region grabs a rectangle in widget-local coordinates (default base: active window). Prefer it over qt_capture_screen_region when correlating to layout geometry from qt_get_geometry / qt_visual_snapshot.
qt_capture_sections takes a list of labeled pieces in one call. Each section may be a full widget (widget_id), a crop of that widget (widget_id + x/y/width/height), or a region on a window. Replies include every PNG plus metadata with label, source (widget | widget_region | region), and paths under sections/<label>.png in the screenshot sandbox — so agents can cite specific crops when verifying UI.
Agent workflow (token-aware, normal for this app)
Cost is expected to be normal when used this way — not “avoid trees forever.”
- First orientation — one
qt_get_widget_tree(optionallyvisible_only=true, or arootpanel) to learn objectNames / hierarchy. GCS is a deep QWidget tree; that call is intentional once per session or after a major UI change. - After that —
qt_find_widgets+qt_get_widget/qt_get_geometry/qt_get_layoutagainst known ids. Do not re-dump the full tree every step. - Visual checks — prefer
qt_capture_widget,qt_capture_region, or labeledqt_capture_sectionsover full-windowqt_capture_window/qt_visual_snapshot. Use a full-window shot when you need overall composition, not for every verification. - Loop — inspect → edit source → restart app → targeted find/get + crops → verify.
Disable the pyqt-runtime MCP in Cursor when you are not doing live UI work (tool schemas still cost context even if unused).
Security
- Binds to
127.0.0.1only. - Screenshots write only inside a sandbox directory (default: temp
pyqt-runtime-mcp/screenshots/)...and paths outside the sandbox are rejected. - Widget IDs are validated. Qt property writes use writable
QMetaPropertyonly. - No eval, exec, shell, or call-by-name Python methods.
Threading model
MCP stdio process --TCP JSON--> bridge acceptor thread
|
| queued Qt signal
v
QApplication thread
|
v
QWidget / QLayout
The IPC thread never touches Qt objects. The GUI thread never waits on the socket.
Architecture
Two processes, JSON only (no pickle):
- In-app bridge (
install_pyqt_mcp) — widget registry (qt://widget/N), inspectors, screenshots, input synthesis. - MCP server (
pyqt-runtime-mcp/python -m pyqt_runtime_mcp) — official MCP Python SDK (MCPServer/ FastMCP fallback), stdio to Cursor.
Wire protocol
TCP on 127.0.0.1, framed as a 4-byte big-endian length followed by UTF-8 JSON (max 16 MiB per frame). A discovery file (%TEMP%/pyqt-runtime-mcp/bridge.json) records the bound port if 8765 is busy.
- Handshake — client sends
{"type": "handshake", "protocol": "pyqt-mcp/1", "token": <optional>}; the bridge replies{"ok": true, "protocol": ..., "port": ...}or{"ok": false, "error": {...}}and closes. - Request —
{"id": <uuid>, "method": <name>, "params": {...}, "timeout": <seconds>}.timeoutis the client's budget; the bridge dispatches to the GUI thread with a slightly shorter deadline so it always answers first. - Response —
{"id": <same uuid>, "ok": true, "result": ...}or{"id": ..., "ok": false, "error": {"code": ..., "message": ...}}.
Connections stay open and are never closed on idle. Requests are matched by id, so a reply to an abandoned request is skipped rather than mistaken for the current one.
Framing rules the client depends on:
- One exchange at a time per connection.
BridgeClientholds a lock, because the MCP SDK runs sync tools on a thread pool and two writers on one socket would corrupt the stream. - A frame length of 0 or above the limit means the stream is out of sync (
PROTOCOL_ERROR); both sides discard the connection instead of trying to resynchronise. - A reused connection is liveness-checked before sending, and a send failure on it is retried once on a fresh socket. A failure after sending is never retried, since the request may have already run.
Troubleshooting
| Symptom | What to check |
|---|---|
APPLICATION_NOT_CONNECTED |
The PyQt app is not running. Start python main.py (or the demo). |
WIDGET_NOT_FOUND |
Stale id after destroy. Widgets are rebuilt when a view is detached, so re-query the id. |
objectName is not unique |
The same name exists in more than one window. Pass window=..., or use a runtime id from the error's candidates. |
TIMEOUT |
The GUI thread is busy or blocked (modal dialog, long handler). The connection is dropped and the next call reconnects. |
PROTOCOL_ERROR (frame too large) |
A reply exceeded 16 MiB, or something else is writing to the bridge port. Narrow the request (max_depth, a specific widget_id). |
| Screenshots empty / tiny | Offscreen platform (QT_QPA_PLATFORM=offscreen) still produces pixmaps but they may look blank. Use a real display for visual QA. |
| Resize ignored | The reply reports matched: false plus the window's min/max size. Maximized windows are restored first and the resize is re-applied once the window manager settles. |
| Window will not close | qt_close_window reports closed: false when closeEvent calls ignore(). Retry with force=true. |
| MCP server import error | pip install pyqt-runtime-mcp in the same Python Cursor uses for command. |
Tests
From this folder:
pip install -e ".[dev]"
python -m pytest -q
(The test suite sets QT_QPA_PLATFORM=offscreen itself.)
Publishing to PyPI
- Bump
__version__insrc/pyqt_runtime_mcp/__init__.py. - Paste your PyPI API token into
pypi_token.txt(gitignored; copy frompypi_token.txt.example). - From this folder:
powershell -File .\publish.ps1
Dry run: powershell -File .\publish.ps1 -TestPyPI. A version can be uploaded once; bump __version__ for every release.
Release files for pyqt-runtime-mcp 0.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| pyqt_runtime_mcp-0.1.1.tar.gz | 62.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pyqt_runtime_mcp-0.1.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 126.7 kB
Release files / pyqt_runtime_mcp-0.1.1.tar.gz
| Download URL | pyqt_runtime_mcp-0.1.1.tar.gz |
|---|---|
| Size | 62.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
b90be522e1e7da843d983a9ff79f39bb9a33a23fb76c082fae03279467c58829
|
|
BLAKE2b-256 checksum How to use checksums |
ea90febeb03896c30ec0ccd52e8e9c8bd61dcf824a5cc68083da5eafefff4573
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.11
|
Release files / pyqt_runtime_mcp-0.1.1-py3-none-any.whl
| Download URL | pyqt_runtime_mcp-0.1.1-py3-none-any.whl |
|---|---|
| Size | 64.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
aac0b1fa18a5944b0d64cdec100e830585c219cedcffea059a1d9f71d98d585d
|
|
BLAKE2b-256 checksum How to use checksums |
b42b098db977c29b0569e7c2baae9880e29b317a85ef6cc792897c89622ea985
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.11
|