pyqt-runtime-mcp 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. pyqt_runtime_mcp-0.1.0/LICENSE +21 -0
  2. pyqt_runtime_mcp-0.1.0/PKG-INFO +297 -0
  3. pyqt_runtime_mcp-0.1.0/README.md +268 -0
  4. pyqt_runtime_mcp-0.1.0/pyproject.toml +50 -0
  5. pyqt_runtime_mcp-0.1.0/setup.cfg +4 -0
  6. pyqt_runtime_mcp-0.1.0/src/pyqt_runtime_mcp/__init__.py +18 -0
  7. pyqt_runtime_mcp-0.1.0/src/pyqt_runtime_mcp/__main__.py +6 -0
  8. pyqt_runtime_mcp-0.1.0/src/pyqt_runtime_mcp/bridge/__init__.py +5 -0
  9. pyqt_runtime_mcp-0.1.0/src/pyqt_runtime_mcp/bridge/bridge.py +381 -0
  10. pyqt_runtime_mcp-0.1.0/src/pyqt_runtime_mcp/bridge/diagnostics.py +248 -0
  11. pyqt_runtime_mcp-0.1.0/src/pyqt_runtime_mcp/bridge/dispatcher.py +78 -0
  12. pyqt_runtime_mcp-0.1.0/src/pyqt_runtime_mcp/bridge/inspectors.py +352 -0
  13. pyqt_runtime_mcp-0.1.0/src/pyqt_runtime_mcp/bridge/interaction.py +348 -0
  14. pyqt_runtime_mcp-0.1.0/src/pyqt_runtime_mcp/bridge/logging.py +219 -0
  15. pyqt_runtime_mcp-0.1.0/src/pyqt_runtime_mcp/bridge/overlay.py +124 -0
  16. pyqt_runtime_mcp-0.1.0/src/pyqt_runtime_mcp/bridge/properties.py +113 -0
  17. pyqt_runtime_mcp-0.1.0/src/pyqt_runtime_mcp/bridge/qtutil.py +116 -0
  18. pyqt_runtime_mcp-0.1.0/src/pyqt_runtime_mcp/bridge/registry.py +235 -0
  19. pyqt_runtime_mcp-0.1.0/src/pyqt_runtime_mcp/bridge/router.py +92 -0
  20. pyqt_runtime_mcp-0.1.0/src/pyqt_runtime_mcp/bridge/screenshots.py +225 -0
  21. pyqt_runtime_mcp-0.1.0/src/pyqt_runtime_mcp/bridge/signals.py +119 -0
  22. pyqt_runtime_mcp-0.1.0/src/pyqt_runtime_mcp/bridge/snapshot.py +138 -0
  23. pyqt_runtime_mcp-0.1.0/src/pyqt_runtime_mcp/bridge/styles.py +65 -0
  24. pyqt_runtime_mcp-0.1.0/src/pyqt_runtime_mcp/bridge/widgets.py +278 -0
  25. pyqt_runtime_mcp-0.1.0/src/pyqt_runtime_mcp/bridge/windows.py +273 -0
  26. pyqt_runtime_mcp-0.1.0/src/pyqt_runtime_mcp/protocol/__init__.py +6 -0
  27. pyqt_runtime_mcp-0.1.0/src/pyqt_runtime_mcp/protocol/client.py +211 -0
  28. pyqt_runtime_mcp-0.1.0/src/pyqt_runtime_mcp/protocol/errors.py +49 -0
  29. pyqt_runtime_mcp-0.1.0/src/pyqt_runtime_mcp/protocol/messages.py +103 -0
  30. pyqt_runtime_mcp-0.1.0/src/pyqt_runtime_mcp/protocol/transport.py +80 -0
  31. pyqt_runtime_mcp-0.1.0/src/pyqt_runtime_mcp/server.py +57 -0
  32. pyqt_runtime_mcp-0.1.0/src/pyqt_runtime_mcp/tools/__init__.py +12 -0
  33. pyqt_runtime_mcp-0.1.0/src/pyqt_runtime_mcp/tools/application.py +99 -0
  34. pyqt_runtime_mcp-0.1.0/src/pyqt_runtime_mcp/tools/common.py +122 -0
  35. pyqt_runtime_mcp-0.1.0/src/pyqt_runtime_mcp/tools/diagnostics.py +95 -0
  36. pyqt_runtime_mcp-0.1.0/src/pyqt_runtime_mcp/tools/interaction.py +160 -0
  37. pyqt_runtime_mcp-0.1.0/src/pyqt_runtime_mcp/tools/screenshots.py +123 -0
  38. pyqt_runtime_mcp-0.1.0/src/pyqt_runtime_mcp/tools/styles.py +42 -0
  39. pyqt_runtime_mcp-0.1.0/src/pyqt_runtime_mcp/tools/widgets.py +87 -0
  40. pyqt_runtime_mcp-0.1.0/src/pyqt_runtime_mcp.egg-info/PKG-INFO +297 -0
  41. pyqt_runtime_mcp-0.1.0/src/pyqt_runtime_mcp.egg-info/SOURCES.txt +52 -0
  42. pyqt_runtime_mcp-0.1.0/src/pyqt_runtime_mcp.egg-info/dependency_links.txt +1 -0
  43. pyqt_runtime_mcp-0.1.0/src/pyqt_runtime_mcp.egg-info/entry_points.txt +2 -0
  44. pyqt_runtime_mcp-0.1.0/src/pyqt_runtime_mcp.egg-info/requires.txt +8 -0
  45. pyqt_runtime_mcp-0.1.0/src/pyqt_runtime_mcp.egg-info/top_level.txt +1 -0
  46. pyqt_runtime_mcp-0.1.0/tests/test_inspection.py +70 -0
  47. pyqt_runtime_mcp-0.1.0/tests/test_interaction.py +59 -0
  48. pyqt_runtime_mcp-0.1.0/tests/test_ipc.py +136 -0
  49. pyqt_runtime_mcp-0.1.0/tests/test_meta.py +53 -0
  50. pyqt_runtime_mcp-0.1.0/tests/test_protocol.py +105 -0
  51. pyqt_runtime_mcp-0.1.0/tests/test_registry.py +50 -0
  52. pyqt_runtime_mcp-0.1.0/tests/test_screenshots.py +82 -0
  53. pyqt_runtime_mcp-0.1.0/tests/test_server.py +20 -0
  54. pyqt_runtime_mcp-0.1.0/tests/test_windows.py +139 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tigran
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,297 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyqt-runtime-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server and in-app bridge for inspecting and controlling running PyQt5 applications
5
+ Author-email: Tigran <tyavroyan@gmail.com>
6
+ License: MIT
7
+ Keywords: pyqt5,mcp,qt,devtools
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development :: User Interfaces
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: mcp>=1.2
23
+ Provides-Extra: qt
24
+ Requires-Dist: PyQt5>=5.15; extra == "qt"
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=8; extra == "dev"
27
+ Requires-Dist: PyQt5>=5.15; extra == "dev"
28
+ Dynamic: license-file
29
+
30
+ # PyQt Runtime MCP
31
+
32
+ Runtime inspection and interaction for **any PyQt5 QWidget application**, in the same spirit as browser DevTools / Playwright — but Qt-native.
33
+
34
+ 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.
35
+
36
+ This package is reusable. GCS is only one consumer.
37
+
38
+ ## Installation
39
+
40
+ From PyPI (after the package is published):
41
+
42
+ ```powershell
43
+ pip install pyqt-runtime-mcp
44
+ ```
45
+
46
+ From this folder (development):
47
+
48
+ ```powershell
49
+ pip install -e .
50
+ ```
51
+
52
+ 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]"`.
53
+
54
+ 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.
55
+
56
+ ## Quick start
57
+
58
+ 1. Run the app (`python main.py` or the demo below). The in-app bridge listens on `127.0.0.1:8765`.
59
+
60
+ 2. Point Cursor at the MCP server (see [Cursor configuration](#cursor-configuration)). Reload MCP servers if you just added it.
61
+
62
+ 3. Ask:
63
+
64
+ ```text
65
+ Inspect the current PyQt application and give me its widget hierarchy.
66
+ ```
67
+
68
+ Demo without GCS (from this folder, with PyQt5 installed):
69
+
70
+ ```powershell
71
+ python examples/demo_app.py
72
+ ```
73
+
74
+ ## Integration into an existing PyQt5 application
75
+
76
+ ```python
77
+ from PyQt5.QtWidgets import QApplication
78
+ from pyqt_runtime_mcp import install_pyqt_mcp
79
+
80
+ app = QApplication(sys.argv)
81
+ window = MainWindow()
82
+
83
+ install_pyqt_mcp(app)
84
+
85
+ window.show()
86
+ sys.exit(app.exec_())
87
+ ```
88
+
89
+ Equivalent:
90
+
91
+ ```python
92
+ from pyqt_runtime_mcp import PyQtMCPBridge
93
+
94
+ bridge = PyQtMCPBridge(app)
95
+ bridge.start()
96
+ ```
97
+
98
+ The bridge **must** be started inside the process that owns `QApplication`. It will not attach to a random already-running PyQt process.
99
+
100
+ ### GCS (this repository)
101
+
102
+ `main.py` always starts the localhost bridge. Run the app as usual:
103
+
104
+ ```powershell
105
+ python .\main.py
106
+ ```
107
+
108
+ If the bridge cannot bind, GCS still runs and logs a warning.
109
+
110
+ ## Starting the MCP server
111
+
112
+ After `pip install pyqt-runtime-mcp`, either command works:
113
+
114
+ ```powershell
115
+ pyqt-runtime-mcp
116
+ python -m pyqt_runtime_mcp
117
+ ```
118
+
119
+ From this source tree without installing, use `python run_server.py`.
120
+
121
+ If the app is not running, tools return `APPLICATION_NOT_CONNECTED` instead of hanging.
122
+
123
+ ## Cursor configuration
124
+
125
+ After a PyPI / editable install, use the console script (same Python that ran `pip install`):
126
+
127
+ ```json
128
+ "pyqt-runtime": {
129
+ "command": "pyqt-runtime-mcp"
130
+ }
131
+ ```
132
+
133
+ Equivalent:
134
+
135
+ ```json
136
+ "pyqt-runtime": {
137
+ "command": "python",
138
+ "args": ["-m", "pyqt_runtime_mcp"]
139
+ }
140
+ ```
141
+
142
+ 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.
143
+
144
+ ## Available MCP tools
145
+
146
+ 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`
147
+
148
+ Visual: `qt_capture_window`, `qt_capture_widget`, `qt_capture_region`, `qt_capture_screen_region`, `qt_capture_sections`, `qt_visual_snapshot`
149
+
150
+ Interaction: `qt_click`, `qt_double_click`, `qt_move_mouse`, `qt_set_focus`, `qt_type_text`, `qt_key_press`
151
+
152
+ Semantic: `qt_set_text`, `qt_set_value`, `qt_set_checked`, `qt_select_combobox`, `qt_set_current_page`
153
+
154
+ Window: `qt_resize_window`, `qt_move_window`, `qt_activate_window`, `qt_close_window`, `qt_close_windows` (`confirm=true` required on both close tools)
155
+
156
+ Style / meta: `qt_get_stylesheet`, `qt_set_stylesheet`, `qt_list_properties`, `qt_get_property`, `qt_set_property`
157
+
158
+ 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`
159
+
160
+ There is **no** `execute_python` / eval / shell tool.
161
+
162
+ ### Runtime logs
163
+
164
+ `qt_get_logs` returns one bounded ring buffer holding both Qt messages (captured through
165
+ `qInstallMessageHandler`) and the application's own stdlib `logging` records (captured with a
166
+ handler added to the root logger). Entries are tagged `source: "qt" | "python"`; filter with
167
+ `source`, `level`, or `contains`. Neither capture changes what the application already prints,
168
+ and the root logger's level is left untouched — records the app filters out stay filtered out.
169
+
170
+ ### Selecting a widget
171
+
172
+ Every tool that takes `widget_id` accepts a runtime id (`qt://widget/N`) or an `objectName`.
173
+ Detaching a view usually clones objectNames into a second window, which makes a bare name
174
+ ambiguous; the error then lists each candidate with the window it lives in. Pass
175
+ `window=<window id or objectName>` to scope the lookup to one window's subtree.
176
+ `qt_find_widgets` takes the same `window` argument.
177
+
178
+ ### Closing windows
179
+
180
+ `qt_close_window` closes one window and verifies the result: `closed` is `false` when a
181
+ `closeEvent` handler refused, and `force=true` then hides and deletes the widget. Dialogs are
182
+ rejected first so an `exec_()` loop unwinds. `qt_close_windows` sweeps every visible top-level
183
+ window — the way to clean up detached views, popups, and leftover dialogs after a test run.
184
+ It protects `QMainWindow` instances unless `include_main=true`, and can be narrowed with
185
+ `windows`, `class_name`, or `title_contains`. The reply splits results into `closed`,
186
+ `failed`, `skipped`, and the `remaining` visible windows.
187
+
188
+ ### Example prompts
189
+
190
+ ```text
191
+ Inspect the current PyQt application and give me its widget hierarchy.
192
+
193
+ Take a screenshot of MainWindow.
194
+
195
+ Find the widget named telemetryPanel and inspect its geometry.
196
+
197
+ Resize MainWindow to 1024x600 and identify layout problems.
198
+
199
+ Find all QLabel widgets whose contents are clipped.
200
+
201
+ Open page 3 of the main QStackedWidget.
202
+
203
+ Click the Settings button.
204
+
205
+ Capture the Settings page.
206
+
207
+ Capture labeled sections for the status bar, emergency rail, and map area so each can be checked separately.
208
+
209
+ Inspect the layout and tell me why the bottom controls are outside the visible area.
210
+ ```
211
+
212
+ ### Section screenshots
213
+
214
+ `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`.
215
+
216
+ `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.
217
+
218
+ ### Agent workflow (token-aware, normal for this app)
219
+
220
+ Cost is expected to be normal when used this way — not “avoid trees forever.”
221
+
222
+ 1. **First orientation** — one `qt_get_widget_tree` (optionally `visible_only=true`, or a `root` panel) to learn objectNames / hierarchy. GCS is a deep QWidget tree; that call is intentional once per session or after a major UI change.
223
+ 2. **After that** — `qt_find_widgets` + `qt_get_widget` / `qt_get_geometry` / `qt_get_layout` against known ids. Do not re-dump the full tree every step.
224
+ 3. **Visual checks** — prefer `qt_capture_widget`, `qt_capture_region`, or labeled `qt_capture_sections` over full-window `qt_capture_window` / `qt_visual_snapshot`. Use a full-window shot when you need overall composition, not for every verification.
225
+ 4. **Loop** — inspect → edit source → restart app → targeted find/get + crops → verify.
226
+
227
+ Disable the `pyqt-runtime` MCP in Cursor when you are not doing live UI work (tool schemas still cost context even if unused).
228
+
229
+ ## Security
230
+
231
+ - Binds to `127.0.0.1` only.
232
+ - Screenshots write only inside a sandbox directory (default: temp `pyqt-runtime-mcp/screenshots/`). `..` and paths outside the sandbox are rejected.
233
+ - Widget IDs are validated. Qt property writes use writable `QMetaProperty` only.
234
+ - No eval, exec, shell, or call-by-name Python methods.
235
+
236
+ ## Threading model
237
+
238
+ ```text
239
+ MCP stdio process --TCP JSON--> bridge acceptor thread
240
+ |
241
+ | queued Qt signal
242
+ v
243
+ QApplication thread
244
+ |
245
+ v
246
+ QWidget / QLayout
247
+ ```
248
+
249
+ The IPC thread never touches Qt objects. The GUI thread never waits on the socket.
250
+
251
+ ## Architecture
252
+
253
+ Two processes, JSON only (no pickle):
254
+
255
+ 1. **In-app bridge** (`install_pyqt_mcp`) — widget registry (`qt://widget/N`), inspectors, screenshots, input synthesis.
256
+ 2. **MCP server** (`pyqt-runtime-mcp` / `python -m pyqt_runtime_mcp`) — official MCP Python SDK (`MCPServer` / FastMCP fallback), stdio to Cursor.
257
+
258
+ ### Wire protocol
259
+
260
+ 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.
261
+
262
+ 1. **Handshake** — client sends `{"type": "handshake", "protocol": "pyqt-mcp/1", "token": <optional>}`; the bridge replies `{"ok": true, "protocol": ..., "port": ...}` or `{"ok": false, "error": {...}}` and closes.
263
+ 2. **Request** — `{"id": <uuid>, "method": <name>, "params": {...}, "timeout": <seconds>}`. `timeout` is the client's budget; the bridge dispatches to the GUI thread with a slightly shorter deadline so it always answers first.
264
+ 3. **Response** — `{"id": <same uuid>, "ok": true, "result": ...}` or `{"id": ..., "ok": false, "error": {"code": ..., "message": ...}}`.
265
+
266
+ 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.
267
+
268
+ Framing rules the client depends on:
269
+
270
+ - One exchange at a time per connection. `BridgeClient` holds a lock, because the MCP SDK runs sync tools on a thread pool and two writers on one socket would corrupt the stream.
271
+ - 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.
272
+ - 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.
273
+
274
+ ## Troubleshooting
275
+
276
+ | Symptom | What to check |
277
+ |---|---|
278
+ | `APPLICATION_NOT_CONNECTED` | The PyQt app is not running. Start `python main.py` (or the demo). |
279
+ | `WIDGET_NOT_FOUND` | Stale id after destroy. Widgets are rebuilt when a view is detached, so re-query the id. |
280
+ | `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`. |
281
+ | `TIMEOUT` | The GUI thread is busy or blocked (modal dialog, long handler). The connection is dropped and the next call reconnects. |
282
+ | `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`). |
283
+ | Screenshots empty / tiny | Offscreen platform (`QT_QPA_PLATFORM=offscreen`) still produces pixmaps but they may look blank. Use a real display for visual QA. |
284
+ | 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. |
285
+ | Window will not close | `qt_close_window` reports `closed: false` when `closeEvent` calls `ignore()`. Retry with `force=true`. |
286
+ | MCP server import error | `pip install pyqt-runtime-mcp` in the same Python Cursor uses for `command`. |
287
+
288
+ ## Tests
289
+
290
+ From this folder:
291
+
292
+ ```powershell
293
+ pip install -e ".[dev]"
294
+ python -m pytest -q
295
+ ```
296
+
297
+ (The test suite sets `QT_QPA_PLATFORM=offscreen` itself.)
@@ -0,0 +1,268 @@
1
+ # PyQt Runtime MCP
2
+
3
+ Runtime inspection and interaction for **any PyQt5 QWidget application**, in the same spirit as browser DevTools / Playwright — but Qt-native.
4
+
5
+ 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.
6
+
7
+ This package is reusable. GCS is only one consumer.
8
+
9
+ ## Installation
10
+
11
+ From PyPI (after the package is published):
12
+
13
+ ```powershell
14
+ pip install pyqt-runtime-mcp
15
+ ```
16
+
17
+ From this folder (development):
18
+
19
+ ```powershell
20
+ pip install -e .
21
+ ```
22
+
23
+ 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]"`.
24
+
25
+ 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.
26
+
27
+ ## Quick start
28
+
29
+ 1. Run the app (`python main.py` or the demo below). The in-app bridge listens on `127.0.0.1:8765`.
30
+
31
+ 2. Point Cursor at the MCP server (see [Cursor configuration](#cursor-configuration)). Reload MCP servers if you just added it.
32
+
33
+ 3. Ask:
34
+
35
+ ```text
36
+ Inspect the current PyQt application and give me its widget hierarchy.
37
+ ```
38
+
39
+ Demo without GCS (from this folder, with PyQt5 installed):
40
+
41
+ ```powershell
42
+ python examples/demo_app.py
43
+ ```
44
+
45
+ ## Integration into an existing PyQt5 application
46
+
47
+ ```python
48
+ from PyQt5.QtWidgets import QApplication
49
+ from pyqt_runtime_mcp import install_pyqt_mcp
50
+
51
+ app = QApplication(sys.argv)
52
+ window = MainWindow()
53
+
54
+ install_pyqt_mcp(app)
55
+
56
+ window.show()
57
+ sys.exit(app.exec_())
58
+ ```
59
+
60
+ Equivalent:
61
+
62
+ ```python
63
+ from pyqt_runtime_mcp import PyQtMCPBridge
64
+
65
+ bridge = PyQtMCPBridge(app)
66
+ bridge.start()
67
+ ```
68
+
69
+ The bridge **must** be started inside the process that owns `QApplication`. It will not attach to a random already-running PyQt process.
70
+
71
+ ### GCS (this repository)
72
+
73
+ `main.py` always starts the localhost bridge. Run the app as usual:
74
+
75
+ ```powershell
76
+ python .\main.py
77
+ ```
78
+
79
+ If the bridge cannot bind, GCS still runs and logs a warning.
80
+
81
+ ## Starting the MCP server
82
+
83
+ After `pip install pyqt-runtime-mcp`, either command works:
84
+
85
+ ```powershell
86
+ pyqt-runtime-mcp
87
+ python -m pyqt_runtime_mcp
88
+ ```
89
+
90
+ From this source tree without installing, use `python run_server.py`.
91
+
92
+ If the app is not running, tools return `APPLICATION_NOT_CONNECTED` instead of hanging.
93
+
94
+ ## Cursor configuration
95
+
96
+ After a PyPI / editable install, use the console script (same Python that ran `pip install`):
97
+
98
+ ```json
99
+ "pyqt-runtime": {
100
+ "command": "pyqt-runtime-mcp"
101
+ }
102
+ ```
103
+
104
+ Equivalent:
105
+
106
+ ```json
107
+ "pyqt-runtime": {
108
+ "command": "python",
109
+ "args": ["-m", "pyqt_runtime_mcp"]
110
+ }
111
+ ```
112
+
113
+ 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.
114
+
115
+ ## Available MCP tools
116
+
117
+ 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`
118
+
119
+ Visual: `qt_capture_window`, `qt_capture_widget`, `qt_capture_region`, `qt_capture_screen_region`, `qt_capture_sections`, `qt_visual_snapshot`
120
+
121
+ Interaction: `qt_click`, `qt_double_click`, `qt_move_mouse`, `qt_set_focus`, `qt_type_text`, `qt_key_press`
122
+
123
+ Semantic: `qt_set_text`, `qt_set_value`, `qt_set_checked`, `qt_select_combobox`, `qt_set_current_page`
124
+
125
+ Window: `qt_resize_window`, `qt_move_window`, `qt_activate_window`, `qt_close_window`, `qt_close_windows` (`confirm=true` required on both close tools)
126
+
127
+ Style / meta: `qt_get_stylesheet`, `qt_set_stylesheet`, `qt_list_properties`, `qt_get_property`, `qt_set_property`
128
+
129
+ 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`
130
+
131
+ There is **no** `execute_python` / eval / shell tool.
132
+
133
+ ### Runtime logs
134
+
135
+ `qt_get_logs` returns one bounded ring buffer holding both Qt messages (captured through
136
+ `qInstallMessageHandler`) and the application's own stdlib `logging` records (captured with a
137
+ handler added to the root logger). Entries are tagged `source: "qt" | "python"`; filter with
138
+ `source`, `level`, or `contains`. Neither capture changes what the application already prints,
139
+ and the root logger's level is left untouched — records the app filters out stay filtered out.
140
+
141
+ ### Selecting a widget
142
+
143
+ Every tool that takes `widget_id` accepts a runtime id (`qt://widget/N`) or an `objectName`.
144
+ Detaching a view usually clones objectNames into a second window, which makes a bare name
145
+ ambiguous; the error then lists each candidate with the window it lives in. Pass
146
+ `window=<window id or objectName>` to scope the lookup to one window's subtree.
147
+ `qt_find_widgets` takes the same `window` argument.
148
+
149
+ ### Closing windows
150
+
151
+ `qt_close_window` closes one window and verifies the result: `closed` is `false` when a
152
+ `closeEvent` handler refused, and `force=true` then hides and deletes the widget. Dialogs are
153
+ rejected first so an `exec_()` loop unwinds. `qt_close_windows` sweeps every visible top-level
154
+ window — the way to clean up detached views, popups, and leftover dialogs after a test run.
155
+ It protects `QMainWindow` instances unless `include_main=true`, and can be narrowed with
156
+ `windows`, `class_name`, or `title_contains`. The reply splits results into `closed`,
157
+ `failed`, `skipped`, and the `remaining` visible windows.
158
+
159
+ ### Example prompts
160
+
161
+ ```text
162
+ Inspect the current PyQt application and give me its widget hierarchy.
163
+
164
+ Take a screenshot of MainWindow.
165
+
166
+ Find the widget named telemetryPanel and inspect its geometry.
167
+
168
+ Resize MainWindow to 1024x600 and identify layout problems.
169
+
170
+ Find all QLabel widgets whose contents are clipped.
171
+
172
+ Open page 3 of the main QStackedWidget.
173
+
174
+ Click the Settings button.
175
+
176
+ Capture the Settings page.
177
+
178
+ Capture labeled sections for the status bar, emergency rail, and map area so each can be checked separately.
179
+
180
+ Inspect the layout and tell me why the bottom controls are outside the visible area.
181
+ ```
182
+
183
+ ### Section screenshots
184
+
185
+ `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`.
186
+
187
+ `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.
188
+
189
+ ### Agent workflow (token-aware, normal for this app)
190
+
191
+ Cost is expected to be normal when used this way — not “avoid trees forever.”
192
+
193
+ 1. **First orientation** — one `qt_get_widget_tree` (optionally `visible_only=true`, or a `root` panel) to learn objectNames / hierarchy. GCS is a deep QWidget tree; that call is intentional once per session or after a major UI change.
194
+ 2. **After that** — `qt_find_widgets` + `qt_get_widget` / `qt_get_geometry` / `qt_get_layout` against known ids. Do not re-dump the full tree every step.
195
+ 3. **Visual checks** — prefer `qt_capture_widget`, `qt_capture_region`, or labeled `qt_capture_sections` over full-window `qt_capture_window` / `qt_visual_snapshot`. Use a full-window shot when you need overall composition, not for every verification.
196
+ 4. **Loop** — inspect → edit source → restart app → targeted find/get + crops → verify.
197
+
198
+ Disable the `pyqt-runtime` MCP in Cursor when you are not doing live UI work (tool schemas still cost context even if unused).
199
+
200
+ ## Security
201
+
202
+ - Binds to `127.0.0.1` only.
203
+ - Screenshots write only inside a sandbox directory (default: temp `pyqt-runtime-mcp/screenshots/`). `..` and paths outside the sandbox are rejected.
204
+ - Widget IDs are validated. Qt property writes use writable `QMetaProperty` only.
205
+ - No eval, exec, shell, or call-by-name Python methods.
206
+
207
+ ## Threading model
208
+
209
+ ```text
210
+ MCP stdio process --TCP JSON--> bridge acceptor thread
211
+ |
212
+ | queued Qt signal
213
+ v
214
+ QApplication thread
215
+ |
216
+ v
217
+ QWidget / QLayout
218
+ ```
219
+
220
+ The IPC thread never touches Qt objects. The GUI thread never waits on the socket.
221
+
222
+ ## Architecture
223
+
224
+ Two processes, JSON only (no pickle):
225
+
226
+ 1. **In-app bridge** (`install_pyqt_mcp`) — widget registry (`qt://widget/N`), inspectors, screenshots, input synthesis.
227
+ 2. **MCP server** (`pyqt-runtime-mcp` / `python -m pyqt_runtime_mcp`) — official MCP Python SDK (`MCPServer` / FastMCP fallback), stdio to Cursor.
228
+
229
+ ### Wire protocol
230
+
231
+ 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.
232
+
233
+ 1. **Handshake** — client sends `{"type": "handshake", "protocol": "pyqt-mcp/1", "token": <optional>}`; the bridge replies `{"ok": true, "protocol": ..., "port": ...}` or `{"ok": false, "error": {...}}` and closes.
234
+ 2. **Request** — `{"id": <uuid>, "method": <name>, "params": {...}, "timeout": <seconds>}`. `timeout` is the client's budget; the bridge dispatches to the GUI thread with a slightly shorter deadline so it always answers first.
235
+ 3. **Response** — `{"id": <same uuid>, "ok": true, "result": ...}` or `{"id": ..., "ok": false, "error": {"code": ..., "message": ...}}`.
236
+
237
+ 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.
238
+
239
+ Framing rules the client depends on:
240
+
241
+ - One exchange at a time per connection. `BridgeClient` holds a lock, because the MCP SDK runs sync tools on a thread pool and two writers on one socket would corrupt the stream.
242
+ - 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.
243
+ - 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.
244
+
245
+ ## Troubleshooting
246
+
247
+ | Symptom | What to check |
248
+ |---|---|
249
+ | `APPLICATION_NOT_CONNECTED` | The PyQt app is not running. Start `python main.py` (or the demo). |
250
+ | `WIDGET_NOT_FOUND` | Stale id after destroy. Widgets are rebuilt when a view is detached, so re-query the id. |
251
+ | `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`. |
252
+ | `TIMEOUT` | The GUI thread is busy or blocked (modal dialog, long handler). The connection is dropped and the next call reconnects. |
253
+ | `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`). |
254
+ | Screenshots empty / tiny | Offscreen platform (`QT_QPA_PLATFORM=offscreen`) still produces pixmaps but they may look blank. Use a real display for visual QA. |
255
+ | 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. |
256
+ | Window will not close | `qt_close_window` reports `closed: false` when `closeEvent` calls `ignore()`. Retry with `force=true`. |
257
+ | MCP server import error | `pip install pyqt-runtime-mcp` in the same Python Cursor uses for `command`. |
258
+
259
+ ## Tests
260
+
261
+ From this folder:
262
+
263
+ ```powershell
264
+ pip install -e ".[dev]"
265
+ python -m pytest -q
266
+ ```
267
+
268
+ (The test suite sets `QT_QPA_PLATFORM=offscreen` itself.)
@@ -0,0 +1,50 @@
1
+ [project]
2
+ name = "pyqt-runtime-mcp"
3
+ dynamic = ["version"]
4
+ description = "MCP server and in-app bridge for inspecting and controlling running PyQt5 applications"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "Tigran", email = "tyavroyan@gmail.com" }]
9
+ keywords = ["pyqt5", "mcp", "qt", "devtools"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Operating System :: OS Independent",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3 :: Only",
17
+ "Programming Language :: Python :: 3.10",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Topic :: Software Development :: User Interfaces",
22
+ ]
23
+ dependencies = [
24
+ "mcp>=1.2",
25
+ ]
26
+
27
+ [project.optional-dependencies]
28
+ qt = ["PyQt5>=5.15"]
29
+ dev = ["pytest>=8", "PyQt5>=5.15"]
30
+
31
+ [project.scripts]
32
+ pyqt-runtime-mcp = "pyqt_runtime_mcp.server:main"
33
+
34
+ [build-system]
35
+ requires = ["setuptools>=61"]
36
+ build-backend = "setuptools.build_meta"
37
+
38
+ [tool.setuptools.dynamic]
39
+ version = { attr = "pyqt_runtime_mcp.__version__" }
40
+
41
+ [tool.setuptools]
42
+ license-files = ["LICENSE"]
43
+
44
+ [tool.setuptools.packages.find]
45
+ where = ["src"]
46
+
47
+ [tool.pytest.ini_options]
48
+ testpaths = ["tests"]
49
+ pythonpath = ["src", "tests"]
50
+ addopts = "-q"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,18 @@
1
+ """Reusable PyQt5 runtime MCP bridge and server.
2
+
3
+ Importing this package does not import PyQt5. The in-app bridge is loaded
4
+ lazily so the Cursor MCP stdio process can run without Qt.
5
+ """
6
+
7
+ from typing import Any
8
+
9
+ __all__ = ["PyQtMCPBridge", "install_pyqt_mcp"]
10
+ __version__ = "0.1.0"
11
+
12
+
13
+ def __getattr__(name: str) -> Any:
14
+ if name in {"PyQtMCPBridge", "install_pyqt_mcp"}:
15
+ from pyqt_runtime_mcp.bridge.bridge import PyQtMCPBridge, install_pyqt_mcp
16
+
17
+ return PyQtMCPBridge if name == "PyQtMCPBridge" else install_pyqt_mcp
18
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,6 @@
1
+ """Allow `python -m pyqt_runtime_mcp` to start the MCP stdio server."""
2
+
3
+ from pyqt_runtime_mcp.server import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,5 @@
1
+ """In-app PyQt5 runtime bridge."""
2
+
3
+ from pyqt_runtime_mcp.bridge.bridge import PyQtMCPBridge, install_pyqt_mcp
4
+
5
+ __all__ = ["PyQtMCPBridge", "install_pyqt_mcp"]