@remcp/runtime 0.2.49 → 0.2.50

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.2.50 — 2026-09-20
6
+
7
+ - Lockstep release with the ReMCP client after the hosted MCP/plugin contract was hardened for current OpenAI/MCP Apps requirements.
8
+ - Computer-use tools retain the 83-tool runtime surface with AI-oriented descriptions, conditional JSON Schema validation, structured outputs, dynamic capability advertising, and semantic Accessibility/DOM-first routing.
9
+ - `browser_navigate` adds `action=new_tab` so CDP automation can create the first page target without a shell/browser-address-bar workaround.
10
+ - Live Linux verification covers Tesseract OCR targeting, XDG RemoteDesktop keyboard/pointer input, Unicode `type_text`, and the complete 39-tool computer-use smoke.
11
+
5
12
  ## 0.2.49 — 2026-09-20
6
13
 
7
14
  - Terminal process tools now return schema-validated structured session state, output ranges, match results and termination facts alongside their human-readable text.
@@ -21,6 +28,9 @@
21
28
  - Chrome DevTools Protocol endpoints are restricted to loopback addresses. New filesystem/document operations continue through the existing resolved-path allowlist, and launch/service/power operations reuse the runtime command policy.
22
29
  - Fixed metadata output truncation once the JSON tool contract exceeded the operating-system pipe buffer by waiting for stdout writes to flush before exit.
23
30
  - Terminal session tools now expose schema-validated structured `pid`, status, exit information, output ranges/matches, and session lists alongside their human-readable text, so hosted agents can chain process calls without parsing prose.
31
+ - Tool descriptions now state the intended routing boundary for exact-path vs glob reads, singular vs batch filesystem operations, full-screen vs targeted/semantic screenshots, and local runtime settings vs hosted device labels.
32
+ - Desktop action schemas and common guards now reject targetless `ui_action`/`window_action`, coordinate-less pointer moves, zero-motion scrolls, incomplete cross-backend `computer_action` targets, and connectivity tests without host/port before platform adapters can act. Empty `ui_find`/`browser_find` calls are redirected to their snapshot tools, and `wait_for_ui` requires an explicit state/condition instead of silently acting as a no-op.
33
+ - `take_screenshot` is correctly advertised as non-read-only/non-idempotent because kept or oversized captures can persist timestamped files; selector-scoped `browser_snapshot` screenshots restore the original page scroll position before returning so the inspection path remains read-only.
24
34
  - No browser/document rendering stack was added; the runtime remains dependency-light and uses native OS facilities plus existing qpdf/poppler/zip helpers when available.
25
35
 
26
36
  ## 0.2.47 — 2026-09-19
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.49",
3
+ "version": "0.2.50",
4
4
  "description": "First-party ReMCP local device runtime: filesystem, terminal, computer-use, browser, diagnostics and document tools over MCP.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/catalog.mjs CHANGED
@@ -109,7 +109,7 @@ export const toolDefinitions = [
109
109
  {
110
110
  name: 'read_files',
111
111
  title: 'Read files by glob',
112
- description: 'Read every file matching a glob under a directory in one call, each section prefixed with its path and line count. Use this to load a whole project area into context quickly instead of one read per file.',
112
+ description: 'Read files discovered by one glob under a directory, each section prefixed with its path and line count. Use this when the exact file list is not already known. If you already know the exact paths, use read_multiple_files; for one known path use read_file.',
113
113
  inputSchema: {
114
114
  type: 'object',
115
115
  properties: {
@@ -127,7 +127,7 @@ export const toolDefinitions = [
127
127
  {
128
128
  name: 'read_multiple_files',
129
129
  title: 'Read multiple files',
130
- description: 'Read several text files in one call. Each file is returned separately and a failure to read one file does not stop the others.',
130
+ description: 'Read several explicitly named text files in one call. Use this when the exact paths are already known; each file is returned separately and one failure does not stop the others. Do not use this for glob discovery: use read_files instead. For one path use read_file.',
131
131
  inputSchema: {
132
132
  type: 'object',
133
133
  properties: {
@@ -239,7 +239,7 @@ export const toolDefinitions = [
239
239
  {
240
240
  name: 'write_file',
241
241
  title: 'Write file',
242
- description: 'Create a file or change its full content. Parent directories are created automatically. Replaces the file by default; use mode "append" to add to the end. For binary data pass encoding-free base64 through write_binary instead.',
242
+ description: 'Create or replace exactly one text file. Parent directories are created automatically; mode "append" adds to the end. Use write_files for two or more independent files, and write_binary for binary bytes.',
243
243
  inputSchema: {
244
244
  type: 'object',
245
245
  properties: {
@@ -256,7 +256,7 @@ export const toolDefinitions = [
256
256
  {
257
257
  name: 'write_files',
258
258
  title: 'Write multiple files',
259
- description: 'Create or replace many files in one call, each with its own path, content, and optional mode. Use this to scaffold a project or apply a multi-file change without one round trip per file.',
259
+ description: 'Create or replace two or more text files in one batch, each with its own path, content, and optional mode. Use this for scaffolding or coherent multi-file writes; for one path use write_file.',
260
260
  inputSchema: {
261
261
  type: 'object',
262
262
  properties: {
@@ -427,7 +427,7 @@ export const toolDefinitions = [
427
427
  {
428
428
  name: 'move_file',
429
429
  title: 'Move or rename',
430
- description: 'Move or rename a file or directory. Replaces an existing destination file by default; pass overwrite false to refuse instead.',
430
+ description: 'Move or rename exactly one file or directory from one source to one destination. Replaces an existing destination file by default; pass overwrite false to refuse instead. Use move_paths for two or more moves.',
431
431
  inputSchema: {
432
432
  type: 'object',
433
433
  properties: {
@@ -444,7 +444,7 @@ export const toolDefinitions = [
444
444
  {
445
445
  name: 'copy_file',
446
446
  title: 'Copy file',
447
- description: 'Copy one file to a new path, replacing the destination by default. Pass overwrite false to refuse an existing destination. Directories are not copied recursively.',
447
+ description: 'Copy exactly one regular file to a new path. Directories are not copied recursively. Use copy_paths for directories or two or more copy operations; pass overwrite false to refuse an existing destination.',
448
448
  inputSchema: {
449
449
  type: 'object',
450
450
  properties: {
@@ -461,7 +461,7 @@ export const toolDefinitions = [
461
461
  {
462
462
  name: 'copy_paths',
463
463
  title: 'Copy paths',
464
- description: 'Copy many files or whole directories in one call, each with its own source and destination. Directories are copied recursively.',
464
+ description: 'Copy two or more paths, or copy a directory recursively, in one call. Each item has its own source and destination. For exactly one regular file use copy_file.',
465
465
  inputSchema: {
466
466
  type: 'object',
467
467
  properties: {
@@ -489,7 +489,7 @@ export const toolDefinitions = [
489
489
  {
490
490
  name: 'move_paths',
491
491
  title: 'Move paths',
492
- description: 'Move or rename many files or whole directories in one call, each with its own source and destination. Falls back to copy-and-delete across filesystems.',
492
+ description: 'Move or rename two or more files/directories in one batch, each with its own source and destination. Falls back to copy-and-delete across filesystems. For one source/destination pair use move_file.',
493
493
  inputSchema: {
494
494
  type: 'object',
495
495
  properties: {
@@ -517,7 +517,7 @@ export const toolDefinitions = [
517
517
  {
518
518
  name: 'delete_path',
519
519
  title: 'Delete path',
520
- description: 'Delete a file or a directory on the computer. Directories are removed with their contents unless recursive is false. The filesystem root is refused.',
520
+ description: 'Delete exactly one file or directory. Directories are removed with their contents unless recursive is false; the filesystem root is refused. Use delete_paths for two or more targets, or move_to_trash when deletion should be reversible.',
521
521
  inputSchema: {
522
522
  type: 'object',
523
523
  properties: {
@@ -533,7 +533,7 @@ export const toolDefinitions = [
533
533
  {
534
534
  name: 'delete_paths',
535
535
  title: 'Delete paths',
536
- description: 'Delete many files and directories in one call, reporting each result. Use move_to_trash instead when the deletion should be reversible.',
536
+ description: 'Delete two or more files/directories in one batch and report each result. For one target use delete_path. Use move_to_trash instead when deletion should be reversible.',
537
537
  inputSchema: {
538
538
  type: 'object',
539
539
  properties: {
@@ -582,7 +582,7 @@ export const toolDefinitions = [
582
582
  {
583
583
  name: 'take_screenshot',
584
584
  title: 'Take screenshot',
585
- description: 'Capture the screen of the paired computer and return it as an image, for GUI work, visual checks, and demonstrating what is on screen. Uses the standard desktop screenshot portal on Wayland, with grim, gnome-screenshot, spectacle, scrot, ImageMagick import, screencapture, or PowerShell fallbacks depending on the platform.',
585
+ description: 'Capture the entire desktop only when full-screen pixels are actually needed. Prefer ui_snapshot or browser_snapshot for semantic inspection, and screenshot_region for one window, monitor, or rectangle. This tool uses the platform screenshot portal/utility and returns an image.',
586
586
  inputSchema: {
587
587
  type: 'object',
588
588
  properties: {
@@ -591,7 +591,8 @@ export const toolDefinitions = [
591
591
  },
592
592
  additionalProperties: false,
593
593
  },
594
- annotations: readOnly,
594
+ // keep=true and oversized fallbacks persist a timestamped PNG, so a repeated call can add files.
595
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
595
596
  handler: fileToolHandlers.take_screenshot,
596
597
  },
597
598
  {
@@ -813,7 +814,7 @@ export const toolDefinitions = [
813
814
  {
814
815
  name: 'set_config_value',
815
816
  title: 'Change runtime setting',
816
- description: 'Change one of this runtime’s own preferences on this computer: telemetryEnabled, maxReadLines, maxBufferedLines or maxOutputBytes. The change applies immediately and is saved to runtime.json. Access roots, blocked commands, the command guardrail, the shell and the write limit cannot be set through MCP — they stay with the person at this computer.',
817
+ description: 'Change one of this local runtime’s own preferences on the selected computer: telemetryEnabled, maxReadLines, maxBufferedLines or maxOutputBytes. This does not rename the paired device; use hosted rename_device for that label. Access roots, blocked commands, shell/guardrails and the write limit remain local-only.',
817
818
  inputSchema: {
818
819
  type: 'object',
819
820
  properties: {
@@ -310,10 +310,12 @@ export async function browserSnapshot(args) {
310
310
 
311
311
  await session.send('Page.enable');
312
312
  const selector = optionalString(args.selector);
313
+ let restoreScroll = null;
313
314
  if (selector) {
314
315
  const bounds = await evaluate(session, `new Promise((resolve,reject) => {
315
316
  const el=document.querySelector(${JSON.stringify(selector)});
316
317
  if(!el){reject(new Error('Screenshot element not found'));return;}
318
+ const originalScrollX=scrollX, originalScrollY=scrollY;
317
319
  const initial=el.getBoundingClientRect();
318
320
  const desiredTop=Math.max(0, scrollY + initial.top - Math.max(0,(innerHeight-initial.height)/2));
319
321
  window.scrollTo({top:desiredTop,left:scrollX,behavior:'instant'});
@@ -328,6 +330,8 @@ export async function browserSnapshot(args) {
328
330
  viewport_height:innerHeight,
329
331
  scroll_x:scrollX,
330
332
  scroll_y:scrollY,
333
+ original_scroll_x:originalScrollX,
334
+ original_scroll_y:originalScrollY,
331
335
  fully_visible:r.x>=0 && r.y>=0 && r.right<=innerWidth && r.bottom<=innerHeight
332
336
  });
333
337
  }));
@@ -335,7 +339,9 @@ export async function browserSnapshot(args) {
335
339
  if (!bounds || !Number.isFinite(Number(bounds.width)) || !Number.isFinite(Number(bounds.height)) || bounds.width <= 0 || bounds.height <= 0) {
336
340
  throw new Error('Screenshot element has invalid bounds');
337
341
  }
338
- payload.screenshot_target = { selector, ...bounds };
342
+ restoreScroll = { x:Number(bounds.original_scroll_x || 0), y:Number(bounds.original_scroll_y || 0) };
343
+ const { original_scroll_x: _originalX, original_scroll_y: _originalY, ...reportedBounds } = bounds;
344
+ payload.screenshot_target = { selector, ...reportedBounds };
339
345
  }
340
346
  payload.screenshot = {
341
347
  scope: 'viewport',
@@ -343,11 +349,18 @@ export async function browserSnapshot(args) {
343
349
  height: await evaluate(session, 'innerHeight').catch(() => null),
344
350
  };
345
351
  const result = jsonResult(payload);
346
- const captured = await session.send('Page.captureScreenshot', {
347
- format: 'png',
348
- fromSurface: true,
349
- captureBeyondViewport: false,
350
- });
352
+ let captured;
353
+ try {
354
+ captured = await session.send('Page.captureScreenshot', {
355
+ format: 'png',
356
+ fromSurface: true,
357
+ captureBeyondViewport: false,
358
+ });
359
+ } finally {
360
+ if (restoreScroll) {
361
+ await evaluate(session, `window.scrollTo({left:${restoreScroll.x},top:${restoreScroll.y},behavior:'instant'})`).catch(() => {});
362
+ }
363
+ }
351
364
  const data = String(captured?.data || '');
352
365
  if (!data) throw new Error('CDP returned no screenshot data');
353
366
  if (Buffer.byteLength(data, 'base64') > 8 * 1024 * 1024) throw new Error('Browser screenshot exceeds the 8 MiB inline image limit');
@@ -393,6 +406,7 @@ function findExpression(args) {
393
406
  }
394
407
 
395
408
  export async function browserFind(args) {
409
+ if (!(args.selector || args.text || args.role)) throw new Error('browser_find requires selector, text, or role; use browser_snapshot to enumerate page structure');
396
410
  return withTarget(args, async (session, target) => {
397
411
  const matches = await evaluate(session, findExpression(args));
398
412
  return jsonResult({ target_id: target.id, count: Array.isArray(matches) ? matches.length : 0, matches: Array.isArray(matches) ? matches : [] });
@@ -34,14 +34,14 @@ const AI_TOOL_DESCRIPTIONS = Object.freeze({
34
34
  window_action: 'Use this to focus, minimize, maximize, restore, move, resize or close one known top-level window. Select it by id/PID/app/title from list_windows; do not use pointer coordinates for window management when this semantic tool can express the action.',
35
35
  launch_app: 'Use this to start a desktop application by executable/path or friendly application name without a shell. Prefer it over start_process for GUI application launch; use start_process for terminal commands, build tools and shell pipelines.',
36
36
  ui_snapshot: 'Use this to inspect native desktop Accessibility/UI Automation semantics for the active app before clicking or typing. Prefer scope=active; use scope=desktop only for cross-application discovery. For a Chromium web page with CDP available, prefer browser_snapshot for page DOM semantics.',
37
- ui_find: 'Use this after ui_snapshot when you need a small set of native UI elements by role/name/label/AutomationId. Prefer returned id or compact label for subsequent ui_action; refresh only when the UI has changed.',
37
+ ui_find: 'Use this after ui_snapshot when you need a small set of native UI elements by role/name/label/AutomationId. It requires an element criterion; use ui_snapshot, not an empty ui_find, to enumerate UI. Prefer returned id or compact label for subsequent ui_action; refresh only when the UI has changed.',
38
38
  ui_action: 'Use this to invoke, focus, edit, select, toggle, expand/collapse, range-edit or scroll a native accessible UI element. Prefer this over pointer clicks because semantic actions survive layout changes; fall back to pointer only when accessibility cannot perform the requested action.',
39
39
  type_text: 'Use this for reliable Unicode text entry into a native semantic target or focused control. Prefer method=auto so Accessibility or clipboard restoration preserves the requested characters regardless of keyboard layout. Use method=keys only for deliberate physical key-by-key input; printable output then follows the current keyboard layout on the computer. Use keyboard for shortcuts, navigation keys and control keys.',
40
40
  keyboard: 'Use this for shortcuts and individual key presses such as Ctrl/Cmd+C, Tab, Enter or Escape. Do not use it to enter normal prose when type_text is available. On Wayland auto uses an already-authorized portal; backend=portal explicitly requests consent.',
41
41
  pointer: 'Use this only when an action truly requires screen coordinates or a semantic UI/browser action is unavailable. Prefer ui_action for native controls and browser_action for web controls; pointer is the coordinate fallback for move/click/button-down/button-up.',
42
42
  drag_drop: 'Use this for a real drag gesture between two UI element ids or coordinates. Prefer element ids from ui_find/ui_snapshot because their current bounds are resolved at execution time; use coordinates only when no semantic endpoints exist.',
43
43
  scroll: 'Use this for native wheel scrolling of a desktop region or semantic UI target. Prefer browser_action scroll_into_view for a known web element and ui_action scroll_into_view for a known accessible desktop element.',
44
- wait_for_ui: 'Use this after a desktop action to wait for a native UI element/window/focus/text condition instead of sleeping or polling screenshots. Prefer semantic presence/absence/change conditions and set a bounded timeout.',
44
+ wait_for_ui: 'Use this after a desktop action to wait for an explicit native UI state or condition instead of sleeping or polling screenshots. Presence/absence waits need a semantic target; use state=changed for a broad tree change and set a bounded timeout.',
45
45
  clipboard: 'Use this for explicit text clipboard read/write/clear operations. Do not use clipboard as a typing workaround directly; type_text already uses safe clipboard paste with restoration when needed.',
46
46
  display_inventory: 'Use this before monitor-specific screenshots, cross-monitor geometry or coordinate work to obtain monitor bounds, scale and primary-display information.',
47
47
  screenshot_region: 'Use this when pixels are required for visual verification but only one window, monitor or rectangle matters. Prefer this over a full-desktop screenshot; use ui_snapshot/browser_snapshot first when semantic structure is sufficient.',
@@ -50,8 +50,8 @@ const AI_TOOL_DESCRIPTIONS = Object.freeze({
50
50
  notification: 'Use this only when the user wants a visible native desktop notification on the paired computer. Do not use it as a substitute for replying in chat.',
51
51
  browser_tabs: 'Use this first for CDP browser automation when the target page/tab is not already unambiguous. It lists debuggable Chromium pages from a loopback-only endpoint; use the returned target_id/title/url to scope later browser tools.',
52
52
  browser_navigate: 'Use this to open a URL in a new debuggable tab or navigate a known Chromium page by URL, back, forward or reload. Prefer it over typing into the address bar; new_tab also bootstraps browser automation when no page target exists yet.',
53
- browser_snapshot: 'Use this to inspect a web page structurally through CDP accessibility. Prefer it over desktop ui_snapshot for page content; add include_screenshot=true only when final pixel/layout verification is needed.',
54
- browser_find: 'Use this to locate visible web elements by CSS selector, text or ARIA role and obtain reusable selectors/bounds. Prefer this before browser_action rather than guessing selectors or coordinates.',
53
+ browser_snapshot: 'Use this to inspect a web page structurally through CDP accessibility. Prefer it over desktop ui_snapshot for page content; add include_screenshot=true only when final pixel/layout verification is needed. A selector may be temporarily centered for capture, but the original page scroll position is restored before the tool returns.',
54
+ browser_find: 'Use this to locate visible web elements by CSS selector, text or ARIA role and obtain reusable selectors/bounds. It requires an element criterion; use browser_snapshot, not an empty browser_find, to enumerate page structure. Prefer this before browser_action rather than guessing selectors or coordinates.',
55
55
  browser_action: 'Use this for deterministic DOM/CDP interaction with a known web element: click/focus/type/value/select/upload/key/scroll or viewport emulation. Prefer it over desktop pointer/ui_action for page content; use browser_evaluate only when the supported actions cannot express the task.',
56
56
  browser_wait: 'Use this after browser navigation/action to wait for selector/text/URL/load/navigation/network-idle instead of fixed sleeps or screenshot polling. Use browser_evaluate separately for JavaScript predicates.',
57
57
  browser_evaluate: 'Use this as the powerful browser escape hatch only when browser_snapshot/find/action/wait cannot express the required page operation or inspection. JavaScript executes in page context and page content is untrusted.',
@@ -244,7 +244,93 @@ function requiredAny(...groups) {
244
244
 
245
245
  function applySchemaRules(name, schema) {
246
246
  const next = structuredClone(schema);
247
- if (name === 'launch_app') {
247
+ if (name === 'computer_action') {
248
+ const semanticTarget = requiredAny(['id'], ['label'], ['name'], ['role'], ['automation_id'], ['selector'], ['browser_text']);
249
+ next.allOf = [
250
+ {
251
+ if:{ properties:{ action:{ const:'click' } }, required:['action'] },
252
+ then:{ anyOf:[...semanticTarget, { required:['ocr_text'] }, { required:['x','y'] }] },
253
+ },
254
+ {
255
+ if:{ properties:{ action:{ enum:['invoke','set_value','select','toggle','expand','collapse','scroll_into_view','set_range_value','add_to_selection','remove_from_selection'] } }, required:['action'] },
256
+ then:{ anyOf:semanticTarget },
257
+ },
258
+ {
259
+ if:{ properties:{ action:{ const:'focus' } }, required:['action'] },
260
+ then:{ anyOf:[...semanticTarget, ...requiredAny(['pid'], ['app'], ['window_title'], ['title'])] },
261
+ },
262
+ {
263
+ if:{ properties:{ action:{ enum:['set_value','set_range_value'] } }, required:['action'] },
264
+ then:{ required:['value'] },
265
+ },
266
+ {
267
+ if:{ properties:{ action:{ const:'multi_select' } }, required:['action'] },
268
+ then:{ required:['targets'] },
269
+ },
270
+ {
271
+ if:{ properties:{ action:{ const:'multi_edit' } }, required:['action'] },
272
+ then:{ required:['edits'] },
273
+ },
274
+ ];
275
+ } else if (name === 'ui_find') {
276
+ next.anyOf = requiredAny(['id'], ['label'], ['name'], ['role'], ['automation_id']);
277
+ } else if (name === 'ui_action') {
278
+ next.allOf = [
279
+ { anyOf:requiredAny(['id'], ['label'], ['name'], ['role'], ['automation_id']) },
280
+ {
281
+ if:{ properties:{ action:{ enum:['set_value','set_range_value'] } }, required:['action'] },
282
+ then:{ required:['value'] },
283
+ },
284
+ ];
285
+ } else if (name === 'wait_for_ui') {
286
+ next.anyOf = requiredAny(['state'], ['condition']);
287
+ next.allOf = [
288
+ {
289
+ if:{ properties:{ condition:{ const:'text_exists' } }, required:['condition'] },
290
+ then:{ required:['text'] },
291
+ },
292
+ {
293
+ if:{ properties:{ condition:{ const:'active_window' } }, required:['condition'] },
294
+ then:{ anyOf:requiredAny(['text'], ['name'], ['window_title']) },
295
+ },
296
+ {
297
+ if:{ properties:{ condition:{ enum:['element_exists','element_enabled'] } }, required:['condition'] },
298
+ then:{ anyOf:requiredAny(['id'], ['label'], ['name'], ['role'], ['automation_id']) },
299
+ },
300
+ {
301
+ if:{ properties:{ state:{ enum:['present','absent'] } }, required:['state'] },
302
+ then:{ anyOf:requiredAny(['id'], ['label'], ['name'], ['role'], ['automation_id']) },
303
+ },
304
+ ];
305
+ } else if (name === 'window_action') {
306
+ next.allOf = [
307
+ { anyOf:requiredAny(['id'], ['pid'], ['app'], ['title']) },
308
+ {
309
+ if:{ properties:{ action:{ const:'move' } }, required:['action'] },
310
+ then:{ required:['x','y'] },
311
+ },
312
+ {
313
+ if:{ properties:{ action:{ const:'resize' } }, required:['action'] },
314
+ then:{ required:['width','height'] },
315
+ },
316
+ {
317
+ if:{ properties:{ action:{ const:'move_resize' } }, required:['action'] },
318
+ then:{ required:['x','y','width','height'] },
319
+ },
320
+ ];
321
+ } else if (name === 'pointer') {
322
+ next.allOf = [{
323
+ if:{ properties:{ action:{ const:'move' } }, required:['action'] },
324
+ then:{ required:['x','y'] },
325
+ }];
326
+ } else if (name === 'scroll') {
327
+ next.anyOf = requiredAny(['delta_x'], ['delta_y'], ['delta'], ['direction']);
328
+ } else if (name === 'network') {
329
+ next.allOf = [{
330
+ if:{ properties:{ action:{ const:'test' } }, required:['action'] },
331
+ then:{ required:['host','port'] },
332
+ }];
333
+ } else if (name === 'launch_app') {
248
334
  next.anyOf = requiredAny(['app'], ['path']);
249
335
  } else if (name === 'keyboard') {
250
336
  next.anyOf = requiredAny(['shortcut'], ['key'], ['keys']);
@@ -259,6 +345,8 @@ function applySchemaRules(name, schema) {
259
345
  { required:['x','y','width','height'] },
260
346
  ...requiredAny(['window_id'], ['pid'], ['app'], ['title'], ['monitor'], ['monitor_index']),
261
347
  ];
348
+ } else if (name === 'browser_find') {
349
+ next.anyOf = requiredAny(['selector'], ['text'], ['role']);
262
350
  } else if (name === 'browser_navigate') {
263
351
  next.allOf = [{
264
352
  if:{ properties:{ action:{ enum:['url','new_tab'] } }, required:['action'] },
@@ -464,7 +552,7 @@ export const extendedToolDefinitions = [
464
552
 
465
553
  define('browser_tabs', 'Browser tabs', 'List debuggable Chrome, Edge, or Chromium page targets from a loopback-only Chrome DevTools Protocol endpoint.', o({ ...browserTarget }), readOnlyLive, browserHandlers.browser_tabs, ['browser_cdp']),
466
554
  define('browser_navigate', 'Browser navigate', 'Open a new debuggable tab or navigate a selected browser page by URL, history back/forward, or reload through Chrome DevTools Protocol.', o({ ...browserTarget, action:e(['url','new_tab','back','forward','reload'], 'Default url when url is provided, otherwise reload. new_tab creates the first page target when needed.'), url:s('Destination URL for action=url or action=new_tab.'), wait:b('Wait for the page load event; default true.'), ignore_cache:b('Reload without cache when action=reload.') }), openMutatingNonDestructive, browserHandlers.browser_navigate, ['browser_cdp']),
467
- define('browser_snapshot', 'Browser snapshot', 'Return the selected browser page accessibility tree through Chrome DevTools Protocol and, when requested, attach a real rendered viewport PNG for visual verification. A selector recenters the target before capture and returns its exact viewport bounds.', o({ ...browserTarget, max_nodes:n('Maximum AX nodes; default 1500.'), include_screenshot:b('Attach a real CDP-rendered viewport PNG screenshot; default false.'), selector:s('When include_screenshot=true, center this CSS-selected element before capture and report its viewport bounds.') }), readOnlyLive, browserHandlers.browser_snapshot, ['browser_cdp']),
555
+ define('browser_snapshot', 'Browser snapshot', 'Return the selected browser page accessibility tree through Chrome DevTools Protocol and, when requested, attach a real rendered viewport PNG for visual verification. A selector is temporarily recentered for capture, its exact viewport bounds are returned, and the prior scroll position is restored before return.', o({ ...browserTarget, max_nodes:n('Maximum AX nodes; default 1500.'), include_screenshot:b('Attach a real CDP-rendered viewport PNG screenshot; default false.'), selector:s('When include_screenshot=true, center this CSS-selected element before capture and report its viewport bounds.') }), readOnlyLive, browserHandlers.browser_snapshot, ['browser_cdp']),
468
556
  define('browser_find', 'Find browser element', 'Find visible DOM elements by CSS selector, text, or ARIA role and return reusable selectors plus text, value, and bounding boxes.', o({ ...browserTarget, selector:s('CSS selector.'), text:s('Visible text substring.'), role:s('ARIA role.'), limit:n() }), readOnlyLive, browserHandlers.browser_find, ['browser_cdp']),
469
557
  define('browser_action', 'Browser element action', 'Click, focus, type/set a value, select, scroll to, upload files, press a key, or set an exact responsive-test viewport in a debuggable browser page using DOM/CDP semantics.', o({ ...browserTarget, action:e(['click','focus','type','set_value','select','scroll_into_view','upload','press','set_viewport']), selector:s(), text:s(), value:s(), text_value:s(), option:s(), path:s(), paths:{type:'array',items:{type:'string'}}, key:s(), width:n('CSS viewport width for set_viewport.'), height:n('CSS viewport height for set_viewport.'), device_scale_factor:n('Device scale factor for set_viewport; default 1.'), mobile:b('Enable mobile emulation for set_viewport.') }, ['action']), openMutating, browserHandlers.browser_action, ['browser_cdp']),
470
558
  define('browser_wait', 'Wait for browser', 'Wait for DOM state, page text, URL, completed loading, a navigation away from the current URL, or a short network-idle period in a debuggable browser page. Use browser_evaluate separately for JavaScript predicates or page-specific inspection.', o({ ...browserTarget, condition:e(['selector','text','url_contains','load','navigation','network_idle']), selector:s(), text:s(), value:s(), poll_ms:n(), idle_ms:n('Required zero-in-flight network quiet window for network_idle; default 500 ms.') }), readOnlyLive, browserHandlers.browser_wait, ['browser_cdp']),
@@ -273,7 +273,15 @@ export async function listWindows(args = {}) {
273
273
  return { ...window, monitor: monitor?.name ?? null, monitor_index: monitor?.index ?? null };
274
274
  }));
275
275
  }
276
- export async function windowAction(args = {}) { return adapter.windowAction(args); }
276
+ export async function windowAction(args = {}) {
277
+ const hasTarget = Boolean(args.id || Number.isInteger(Number(args.pid)) || args.app || args.title);
278
+ if (!hasTarget) throw new Error('window_action requires id, pid, app, or title');
279
+ const action = optionalString(args.action);
280
+ if (action === 'move' && ![args.x,args.y].every(value => Number.isFinite(Number(value)))) throw new Error('window_action move requires x and y');
281
+ if (action === 'resize' && ![args.width,args.height].every(value => Number.isFinite(Number(value)))) throw new Error('window_action resize requires width and height');
282
+ if (action === 'move_resize' && ![args.x,args.y,args.width,args.height].every(value => Number.isFinite(Number(value)))) throw new Error('window_action move_resize requires x, y, width and height');
283
+ return adapter.windowAction(args);
284
+ }
277
285
  export async function uiSnapshot(args = {}) {
278
286
  const explicitlyScoped = args.pid != null || args.app || args.window_title || args.windowTitle;
279
287
  const activeOnly = args.active_only != null
@@ -285,10 +293,18 @@ export async function uiSnapshot(args = {}) {
285
293
  return jsonResult(enrichUiPayload(value, effective));
286
294
  }
287
295
  export async function uiAction(args = {}) {
288
- return adapter.uiAction(withResolvedUiLabel(args));
296
+ const resolved = withResolvedUiLabel(args);
297
+ const hasTarget = Boolean(resolved.id || resolved.label != null || resolved.name || resolved.role || resolved.automation_id || resolved.automationId);
298
+ if (!hasTarget) throw new Error('ui_action requires id, label, name, role, or automation_id');
299
+ const action = optionalString(resolved.action);
300
+ if (['set_value','set_range_value'].includes(action) && resolved.value == null) throw new Error(`ui_action ${action} requires value`);
301
+ return adapter.uiAction(resolved);
289
302
  }
290
303
  export async function keyboard(args = {}) { return adapter.keyboard(args); }
291
- export async function pointer(args = {}) { return adapter.pointer(args); }
304
+ export async function pointer(args = {}) {
305
+ if (args.action === 'move' && ![args.x,args.y].every(value => Number.isFinite(Number(value)))) throw new Error('pointer move requires x and y');
306
+ return adapter.pointer(args);
307
+ }
292
308
  export async function clipboard(args = {}) { return adapter.clipboard(args); }
293
309
  export async function displayInventory(args = {}) { return adapter.displayInventory(args); }
294
310
  export async function cursorPosition(args = {}) { return typeof adapter.cursorPosition === 'function' ? adapter.cursorPosition(args) : jsonResult({ x:null, y:null }); }
@@ -334,6 +350,8 @@ export async function screenshotRegion(args = {}) {
334
350
  export async function notification(args = {}) { return adapter.notification(args); }
335
351
  export async function scroll(args = {}) {
336
352
  const direction = optionalString(args.direction);
353
+ const hasDelta = [args.delta_x,args.delta_y,args.delta].some(value => value != null && Number.isFinite(Number(value)));
354
+ if (!direction && !hasDelta) throw new Error('scroll requires direction or a delta');
337
355
  const times = clamp(args.wheel_times, 1, 1, 50);
338
356
  let deltaX = Number(args.delta_x || 0);
339
357
  let deltaY = Number(args.delta_y ?? args.delta ?? 0);
@@ -403,6 +421,8 @@ async function uiMatches(args = {}) {
403
421
  }
404
422
 
405
423
  export async function uiFind(args = {}) {
424
+ const hasSelector = Boolean(args.id || args.label != null || args.name || args.role || args.automation_id || args.automationId);
425
+ if (!hasSelector) throw new Error('ui_find requires id, label, name, role, or automation_id; use ui_snapshot to enumerate UI');
406
426
  const nodes = await uiMatches(args);
407
427
  return jsonResult({ count: nodes.length, nodes });
408
428
  }
@@ -473,11 +493,17 @@ export async function waitForUi(args = {}) {
473
493
  const timeoutMs = clamp(args.timeout_ms, 10_000, 100, 120_000);
474
494
  const pollMs = clamp(args.poll_ms, 250, 50, 5000);
475
495
  const rawCondition = optionalString(args.condition);
496
+ const rawState = optionalString(args.state);
497
+ if (!rawCondition && !rawState) throw new Error('wait_for_ui requires state or condition');
476
498
  const aliases = { text:'text_exists', window:'active_window', element:'element_exists', enabled:'element_enabled', focused:'focused_element' };
477
499
  const condition = rawCondition
478
500
  ? requireEnum(aliases[rawCondition] || rawCondition, 'condition', ['text_exists','active_window','element_exists','element_enabled','focused_element'])
479
501
  : null;
480
- const state = condition ? null : requireEnum(args.state || 'present', 'state', ['present', 'absent', 'changed']);
502
+ const state = condition ? null : requireEnum(rawState, 'state', ['present', 'absent', 'changed']);
503
+ const hasSelector = Boolean(args.id || args.label != null || args.name || args.role || args.automation_id || args.automationId);
504
+ if (['element_exists','element_enabled'].includes(condition) && !hasSelector) throw new Error(`wait_for_ui condition=${condition} requires a semantic target`);
505
+ if (condition === 'active_window' && !(args.text || args.name || args.window_title || args.windowTitle)) throw new Error('wait_for_ui condition=active_window requires text, name, or window_title');
506
+ if (['present','absent'].includes(state) && !hasSelector) throw new Error(`wait_for_ui state=${state} requires a semantic target`);
481
507
  const deadline = Date.now() + timeoutMs;
482
508
  const startedAt = Date.now();
483
509
  let attempts = 0;