Skip to content

Commit c83d250

Browse files
committed
Add window geometry MCP tools
Fill the gap left by ac_focus_window / ac_close_window with ac_window_move (combined move + resize via the new MoveWindow helper), ac_window_minimize / _maximize / _restore on top of ShowWindow with the right SW_* flags. All tools resolve a matching hwnd via find_window first so the model can target by title substring instead of tracking handles itself.
1 parent 99c8380 commit c83d250

4 files changed

Lines changed: 153 additions & 1 deletion

File tree

je_auto_control/utils/mcp_server/tools/_factories.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,53 @@ def window_tools() -> List[MCPTool]:
288288
handler=h.close_window,
289289
annotations=DESTRUCTIVE,
290290
),
291+
MCPTool(
292+
name="ac_window_move",
293+
description=("Move and resize the first matching window to "
294+
"(x, y) with dimensions (width, height). "
295+
"Windows-only."),
296+
input_schema=schema({
297+
"title_substring": {"type": "string"},
298+
"x": {"type": "integer"},
299+
"y": {"type": "integer"},
300+
"width": {"type": "integer"},
301+
"height": {"type": "integer"},
302+
"case_sensitive": {"type": "boolean"},
303+
}, required=["title_substring", "x", "y", "width", "height"]),
304+
handler=h.window_move,
305+
annotations=DESTRUCTIVE,
306+
),
307+
MCPTool(
308+
name="ac_window_minimize",
309+
description="Minimise the first matching window.",
310+
input_schema=schema({
311+
"title_substring": {"type": "string"},
312+
"case_sensitive": {"type": "boolean"},
313+
}, required=["title_substring"]),
314+
handler=h.window_minimize,
315+
annotations=DESTRUCTIVE,
316+
),
317+
MCPTool(
318+
name="ac_window_maximize",
319+
description="Maximise the first matching window.",
320+
input_schema=schema({
321+
"title_substring": {"type": "string"},
322+
"case_sensitive": {"type": "boolean"},
323+
}, required=["title_substring"]),
324+
handler=h.window_maximize,
325+
annotations=DESTRUCTIVE,
326+
),
327+
MCPTool(
328+
name="ac_window_restore",
329+
description=("Restore the first matching window to its previous "
330+
"size and position."),
331+
input_schema=schema({
332+
"title_substring": {"type": "string"},
333+
"case_sensitive": {"type": "boolean"},
334+
}, required=["title_substring"]),
335+
handler=h.window_restore,
336+
annotations=DESTRUCTIVE,
337+
),
291338
]
292339

293340

je_auto_control/utils/mcp_server/tools/_handlers.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,51 @@ def close_window(title_substring: str,
427427
case_sensitive=case_sensitive))
428428

429429

430+
def _resolve_window_hwnd(title_substring: str,
431+
case_sensitive: bool) -> int:
432+
from je_auto_control.wrapper.auto_control_window import find_window
433+
hit = find_window(title_substring, case_sensitive=case_sensitive)
434+
if hit is None:
435+
raise ValueError(f"no window matches {title_substring!r}")
436+
return int(hit[0])
437+
438+
439+
def window_move(title_substring: str, x: int, y: int,
440+
width: int, height: int,
441+
case_sensitive: bool = False) -> Dict[str, int]:
442+
"""Move and resize the first window matching ``title_substring`` (Win32 only)."""
443+
from je_auto_control.windows.window import windows_window_manage as wm
444+
hwnd = _resolve_window_hwnd(title_substring, bool(case_sensitive))
445+
if not wm.move_window(hwnd, int(x), int(y), int(width), int(height)):
446+
raise RuntimeError("MoveWindow returned 0")
447+
return {"hwnd": hwnd, "x": int(x), "y": int(y),
448+
"width": int(width), "height": int(height)}
449+
450+
451+
def _show_command(title_substring: str, case_sensitive: bool,
452+
cmd_show: int) -> int:
453+
"""Resolve the window then call ShowWindow with the given cmd."""
454+
from je_auto_control.windows.window import windows_window_manage as wm
455+
hwnd = _resolve_window_hwnd(title_substring, bool(case_sensitive))
456+
wm.show_window(hwnd, int(cmd_show))
457+
return hwnd
458+
459+
460+
def window_minimize(title_substring: str,
461+
case_sensitive: bool = False) -> int:
462+
return _show_command(title_substring, bool(case_sensitive), cmd_show=6)
463+
464+
465+
def window_maximize(title_substring: str,
466+
case_sensitive: bool = False) -> int:
467+
return _show_command(title_substring, bool(case_sensitive), cmd_show=3)
468+
469+
470+
def window_restore(title_substring: str,
471+
case_sensitive: bool = False) -> int:
472+
return _show_command(title_substring, bool(case_sensitive), cmd_show=9)
473+
474+
430475
def get_clipboard() -> str:
431476
from je_auto_control.utils.clipboard.clipboard import get_clipboard as _get
432477
return _get()

je_auto_control/windows/window/windows_window_manage.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,4 +87,15 @@ def show_window(hwnd: int, cmd_show: int) -> None:
8787
if cmd_show < 0 or cmd_show > 11: # Win32 ShowWindow 常見範圍
8888
cmd_show = 1 # 預設為 Normal
8989
user32.ShowWindow(hwnd, cmd_show)
90-
user32.SetForegroundWindow(hwnd)
90+
user32.SetForegroundWindow(hwnd)
91+
92+
93+
def move_window(hwnd: int, x: int, y: int, width: int, height: int,
94+
repaint: bool = True) -> bool:
95+
"""
96+
搬移與調整視窗大小 (一次設定座標與寬高)
97+
Move and resize a window in one call.
98+
"""
99+
return bool(user32.MoveWindow(int(hwnd), int(x), int(y),
100+
int(width), int(height),
101+
c_bool(bool(repaint))))

test/unit_test/headless/test_mcp_server.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1301,6 +1301,55 @@ def test_wait_for_pixel_times_out_when_color_never_matches(monkeypatch):
13011301
raise AssertionError("expected TimeoutError")
13021302

13031303

1304+
def test_window_geometry_tools_present_in_default_registry():
1305+
names = {tool.name for tool in build_default_tool_registry()}
1306+
assert {"ac_window_move", "ac_window_minimize",
1307+
"ac_window_maximize", "ac_window_restore"}.issubset(names)
1308+
1309+
1310+
def test_window_move_calls_into_windows_manager(monkeypatch):
1311+
import je_auto_control.utils.mcp_server.tools._handlers as handlers
1312+
import je_auto_control.wrapper.auto_control_window as window_module
1313+
monkeypatch.setattr(window_module, "find_window",
1314+
lambda title, case_sensitive=False: (123, title))
1315+
captured = {}
1316+
1317+
def fake_move(hwnd, x, y, width, height, repaint=True):
1318+
captured["call"] = (int(hwnd), int(x), int(y),
1319+
int(width), int(height))
1320+
return True
1321+
1322+
from je_auto_control.windows.window import windows_window_manage
1323+
monkeypatch.setattr(windows_window_manage, "move_window", fake_move)
1324+
1325+
by_name = {tool.name: tool for tool in build_default_tool_registry()}
1326+
record = by_name["ac_window_move"].invoke({
1327+
"title_substring": "Notepad",
1328+
"x": 10, "y": 20, "width": 800, "height": 600,
1329+
})
1330+
assert captured["call"] == (123, 10, 20, 800, 600)
1331+
assert record == {"hwnd": 123, "x": 10, "y": 20,
1332+
"width": 800, "height": 600}
1333+
1334+
1335+
def test_window_minimize_uses_show_command_six(monkeypatch):
1336+
"""ShowWindow flag 6 is SW_MINIMIZE."""
1337+
import je_auto_control.wrapper.auto_control_window as window_module
1338+
monkeypatch.setattr(window_module, "find_window",
1339+
lambda title, case_sensitive=False: (456, title))
1340+
seen = {}
1341+
1342+
def fake_show(hwnd, cmd_show):
1343+
seen["call"] = (int(hwnd), int(cmd_show))
1344+
1345+
from je_auto_control.windows.window import windows_window_manage
1346+
monkeypatch.setattr(windows_window_manage, "show_window", fake_show)
1347+
1348+
by_name = {tool.name: tool for tool in build_default_tool_registry()}
1349+
by_name["ac_window_minimize"].invoke({"title_substring": "Notepad"})
1350+
assert seen["call"] == (456, 6)
1351+
1352+
13041353
def test_default_registry_lists_core_automation_tools():
13051354
names = {tool.name for tool in build_default_tool_registry()}
13061355
expected = {

0 commit comments

Comments
 (0)