@remcp/runtime 0.2.175 → 0.2.177

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
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.176 — 2026-10-23
4
+
5
+ - Reduce screenshot CDP round-trips by batching viewport resolution with capture and reusing measured element dimensions.
6
+ - Cache Accessibility enablement per WebSocket connection with single-flight behavior.
7
+ - Filter malformed, mismatched or unsafe page WebSocket targets during discovery rather than exposing unusable tabs.
8
+ - Keep browser URL/DNS validation, permissions and the existing 83 operation definitions unchanged.
9
+
3
10
  ## 0.2.175 — 2026-10-22
4
11
 
5
12
  - Carry forward the public 0.2.174 browser-policy and diagnostics-recording module extraction without weakening CDP or recording controls.
package/README.md CHANGED
@@ -31,8 +31,8 @@ node ./src/compact.mjs --print-tools # compact local façade for scanners/local
31
31
 
32
32
  ## Tools
33
33
 
34
- 83 tools are implemented in this repository: the original 44 filesystem/search/terminal/process tools
35
- plus 39 computer-use, browser, diagnostics, and document tools. The release contract contains all 83;
34
+ 84 tools are implemented in this repository: the original 44 filesystem/search/terminal/process tools
35
+ plus 40 computer-use, browser, diagnostics, and document tools. The release contract contains all 84;
36
36
  the live local MCP `tools/list` omits optional platform-specific tools that the current computer cannot
37
37
  support and emits `notifications/tools/list_changed` when that capability set changes. The stdio server is dual-era: existing 2025-era clients continue to work unchanged, while clients that negotiate MCP `2026-07-28` use `server/discover`, cache hints, unrestricted structured output validation, and `subscriptions/listen` for list-change delivery.
38
38
 
@@ -52,10 +52,10 @@ support and emits `notifications/tools/list_changed` when that capability set ch
52
52
  | Diagnostics | `service`, `event_log`, `network`, `installed_apps`, `environment`, `audio`, `power_action`, `record_screen` — `audio` returns stable structured `volume`, `muted`, `action`, and `backend` fields across supported OS backends |
53
53
  | Documents | `read_document`, `edit_spreadsheet`, `edit_document`, `pdf_action` |
54
54
 
55
- The hosted ReMCP endpoint does not append those 83 granular operation names to its discovery list.
55
+ The hosted ReMCP endpoint does not append those 84 granular operation names to its discovery list.
56
56
  Current production runs with `REMCP_CUSTOM_WIDGETS_ENABLED=false` and exposes **10 hosted tool definitions**,
57
57
  all model-visible: 8 device-facing façade tools plus 2 account/fleet tools. Those compact definitions route
58
- to the same **83 granular runtime operations** after the selected computer's live capabilities are checked.
58
+ to the same **84 granular runtime operations** after the selected computer's live capabilities are checked.
59
59
  If custom widgets are explicitly re-enabled, hosted discovery expands to **15 definitions** by adding
60
60
  **5 app-only** UI helpers. Cached legacy clients can still call supported granular aliases without inflating
61
61
  discovery for new clients.
@@ -77,7 +77,7 @@ system tools such as qpdf/poppler when present. PDF text reading uses the built-
77
77
  `sharp`, or `exceljs`.
78
78
 
79
79
  `--print-tools` prints the exact JSON contract (schemas and annotations) the runtime advertises, and
80
- `src/catalog.mjs` plus `src/extended/catalog.mjs` are the source of truth for the 83-tool release surface.
80
+ `src/catalog.mjs` plus `src/extended/catalog.mjs` are the source of truth for the 84-tool release surface.
81
81
  A running device can advertise a smaller capability-aware subset; the runtime declares
82
82
  `tools.listChanged`, emits `notifications/tools/list_changed` when dynamic capabilities change, and the
83
83
  agent forwards the refreshed tool names to the hosted relay. Browser/CDP tools are the primary dynamic
@@ -90,7 +90,7 @@ errors. Linux `display_inventory` follows the real monitor backend chain (GNOME
90
90
  backend (Wayland grim/GNOME/portal path or X11 ImageMagick), not merely because `ffmpeg` is installed.
91
91
 
92
92
  The generated hosted reference covers the current **10 hosted production definitions**, all model-visible
93
- and routing to the 83 granular runtime operations described above; the optional widget-enabled surface is 15.
93
+ and routing to the 84 granular runtime operations described above; the optional widget-enabled surface is 15.
94
94
  The separate compact local stdio entrypoint advertises `read_file` plus up to seven domain tools
95
95
  (up to 8 tools total) and keeps those groups capability-aware at runtime with `tools/list_changed`; the paired agent keeps using the capability-aware granular runtime.
96
96
  See https://remcp.site/docs#tools and public `docs/TOOLS.md`.
@@ -256,7 +256,7 @@ MCP server.
256
256
 
257
257
  | | Desktop Commander 0.2.50 | ReMCP runtime |
258
258
  | --- | --- | --- |
259
- | Tools | 26, including config mutators and document tooling | 83, including filesystem/terminal plus native desktop UI, browser CDP, diagnostics, and lightweight document operations |
259
+ | Tools | 26, including config mutators and document tooling | 84, including filesystem/terminal plus native desktop UI, browser CDP, diagnostics, and lightweight document operations |
260
260
  | Runtime dependencies | 34 (Supabase, Puppeteer/md-to-pdf, `sharp`, `exceljs`, Tiptap, ripgrep download) | 2 production-direct (`@modelcontextprotocol/server` v2, `@jellybrick/dbus-next`); legacy/modern clients are dev-only compatibility tests |
261
261
  | Install scripts | `postinstall` posts an install payload that ignores the telemetry setting | none |
262
262
  | Telemetry | opt-out, 51 event names, remote feature flags, A/B assignment, third-party processor | opt-out, whitelisted event schema, no endpoint, no flags |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.175",
3
+ "version": "0.2.177",
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",
@@ -18,7 +18,7 @@ export const compactRuntimeGroups = Object.freeze({
18
18
  ]),
19
19
  view_image: Object.freeze(['read_image','take_screenshot','screenshot_region']),
20
20
  control_browser: Object.freeze([
21
- 'browser_tabs','browser_navigate','browser_snapshot','browser_find','browser_action','browser_wait','browser_evaluate',
21
+ 'browser_tabs','browser_navigate','browser_snapshot','browser_find','browser_action','browser_wait','browser_devtools','browser_evaluate',
22
22
  ]),
23
23
  manage_system: Object.freeze([
24
24
  'get_system_info','list_processes','kill_process','get_runtime_info','get_runtime_stats','set_config_value',
@@ -14,6 +14,7 @@ import { callPageFunction, evaluate, withTarget, productionSessionKey } from './
14
14
  import { rememberBrowserViewport } from './browser-viewport.mjs';
15
15
  import { browserNavigate, browserSnapshot, browserTabs, findExpression } from './browser-read.mjs';
16
16
  import { executeBrowserWorkflow, validateBrowserWorkflow } from './browser-workflow.mjs';
17
+ import { browserDevtools } from './browser-devtools.mjs';
17
18
 
18
19
  export async function browserFind(args) {
19
20
  if (!(args.selector || args.text || args.role)) throw new Error('browser_find requires selector, text, or role; use browser_snapshot to enumerate page structure');
@@ -387,4 +388,5 @@ export const browserHandlers = {
387
388
  browser_action: browserAction,
388
389
  browser_wait: browserWait,
389
390
  browser_evaluate: browserEvaluate,
391
+ browser_devtools: browserDevtools,
390
392
  };
@@ -0,0 +1,109 @@
1
+ // Native Chrome DevTools Protocol diagnostics. No screenshots, request bodies,
2
+ // authentication headers, cookies, or response bodies are collected.
3
+ import { jsonResult, clamp, requireEnum } from './common.mjs';
4
+ import { withTarget } from './browser-session.mjs';
5
+ import { inspectNativeBrowserAudit } from './browser-native-audit.mjs';
6
+
7
+ const METRIC_NAMES = new Set([
8
+ 'Timestamp','Documents','Frames','JSEventListeners','Nodes','LayoutCount',
9
+ 'RecalcStyleCount','LayoutDuration','RecalcStyleDuration','ScriptDuration',
10
+ 'TaskDuration','JSHeapUsedSize','JSHeapTotalSize','DomContentLoaded','NavigationStart',
11
+ ]);
12
+ const HIDDEN = '[redacted]';
13
+ export function safeDevtoolsUrl(value) {
14
+ try {
15
+ const url = new URL(String(value || ''));
16
+ if (!['http:', 'https:'].includes(url.protocol)) return '[non-http resource]';
17
+ // No fragments, credentials or query parameters reach the relay.
18
+ return (url.origin + url.pathname).slice(0, 300);
19
+ } catch { return '[unavailable]'; }
20
+ }
21
+ export function safeDevtoolsMessage(value) {
22
+ let text = String(value ?? '').slice(0, 700);
23
+ text = text.replace(/\b(Bearer|Basic)\s+[a-z\d+/_=.-]+/gi, '$1 ' + HIDDEN)
24
+ .replace(/\b(?:eyJ[a-zA-Z0-9_-]{12,}\.){2}[a-zA-Z0-9_-]+\b/g, HIDDEN)
25
+ .replace(/([?&](?:token|key|secret|password|session|code|authorization)=)[^\s&#]+/gi, '$1' + HIDDEN)
26
+ .replace(/\b(?:password|access_token|refresh_token|api_key|client_secret)\s*[:=]\s*[^\s,;&]+/gi, HIDDEN);
27
+ return text.slice(0, 500);
28
+ }
29
+ export function normalizeDevtoolsMetrics(entries) {
30
+ return Object.fromEntries((Array.isArray(entries) ? entries : [])
31
+ .filter(item => METRIC_NAMES.has(item?.name) && Number.isFinite(item?.value))
32
+ .map(item => [item.name, item.value]));
33
+ }
34
+ export async function sampleBrowserDevtools(session, action, durationMs, maxEvents) {
35
+ const events = [];
36
+ const counts = {};
37
+ const requests = new Map();
38
+ function add(event) {
39
+ const key = event.level || event.kind || 'other';
40
+ counts[key] = (counts[key] || 0) + 1;
41
+ if (events.length < maxEvents) events.push(event);
42
+ }
43
+ if (action === 'layout' || action === 'accessibility') return inspectNativeBrowserAudit(session, action);
44
+ if (action === 'performance') {
45
+ await session.send('Performance.enable');
46
+ try {
47
+ const data = await session.send('Performance.getMetrics');
48
+ return { action, metrics:normalizeDevtoolsMetrics(data.metrics) };
49
+ } finally { await session.send('Performance.disable').catch(() => {}); }
50
+ }
51
+ const unsubscribe = [];
52
+ if (action === 'console') {
53
+ unsubscribe.push(session.observe('Runtime.consoleAPICalled', params => {
54
+ const level = String(params.type || 'log').slice(0, 24);
55
+ const previews = (params.args || []).slice(0, 3).map(arg =>
56
+ arg?.type === 'string' ? arg.value : arg?.description || (arg?.value == null ? arg?.type : String(arg.value)));
57
+ add({ kind:'console', level, message:safeDevtoolsMessage(previews.join(' ')) });
58
+ }));
59
+ unsubscribe.push(session.observe('Runtime.exceptionThrown', params => {
60
+ add({ kind:'exception', level:'error', message:safeDevtoolsMessage(params.exceptionDetails?.text || 'Uncaught exception') });
61
+ }));
62
+ unsubscribe.push(session.observe('Log.entryAdded', params => {
63
+ add({ kind:'log', level:String(params.entry?.level || 'info').slice(0, 24),
64
+ message:safeDevtoolsMessage(params.entry?.text || '') });
65
+ }));
66
+ } else {
67
+ unsubscribe.push(session.observe('Network.requestWillBeSent', params => {
68
+ const id = String(params.requestId || '');
69
+ if (requests.size > 1000) requests.clear();
70
+ const record = { kind:'request', method:String(params.request?.method || 'GET').slice(0, 12),
71
+ resource_type:String(params.type || 'Other').slice(0, 35), url:safeDevtoolsUrl(params.request?.url) };
72
+ requests.set(id,record);
73
+ add(record);
74
+ }));
75
+ unsubscribe.push(session.observe('Network.responseReceived', params => {
76
+ const original = requests.get(String(params.requestId || ''));
77
+ if (!original) return;
78
+ const status = Number(params.response?.status);
79
+ original.status = Number.isFinite(status) ? status : null;
80
+ original.resource_type = String(params.type || original.resource_type).slice(0,35);
81
+ }));
82
+ unsubscribe.push(session.observe('Network.loadingFailed', params => {
83
+ const original = requests.get(String(params.requestId || ''));
84
+ if (original) original.failed = true;
85
+ }));
86
+ }
87
+ try {
88
+ if (action === 'console') {
89
+ await session.send('Runtime.enable');
90
+ await session.send('Log.enable');
91
+ } else await session.send('Network.enable', { maxTotalBufferSize: 1_000_000 });
92
+ await new Promise(resolve => setTimeout(resolve, durationMs));
93
+ } finally {
94
+ for (const stop of unsubscribe) stop();
95
+ if (action === 'console') {
96
+ await Promise.allSettled([session.send('Log.disable'), session.send('Runtime.disable')]);
97
+ } else await session.send('Network.disable').catch(() => {});
98
+ }
99
+ return { action, sampled_ms:durationMs, counts, events, truncated:Object.values(counts).reduce((a,b) => a+b,0) > maxEvents,
100
+ note:'Live events only; DevTools does not backfill events recorded before sampling. Request/response contents and headers are excluded.' };
101
+ }
102
+ export async function browserDevtools(args = {}) {
103
+ const action = requireEnum(args.action, 'action', ['console','network','performance','layout','accessibility']);
104
+ const durationMs = clamp(args.sample_ms, 1500, 250, 5000);
105
+ const maxEvents = clamp(args.max_events, 50, 1, 100);
106
+ return withTarget(args, async (session,target) =>
107
+ jsonResult({ target_id:target.id, url:safeDevtoolsUrl(target.url),
108
+ ...(await sampleBrowserDevtools(session,action,durationMs,maxEvents)) }));
109
+ }
@@ -173,12 +173,18 @@ export async function browserCapabilityAvailable(endpoint, timeoutMs = 500) {
173
173
  // Validating every tab sequentially can require hundreds of serial DNS lookups,
174
174
  // making browser_tabs exceed the relay's deadline on machines with many tabs.
175
175
  // Keep DNS and security validation, but bound both the work and its concurrency.
176
- export async function filterBrowserTargets(list, { validate = assertResolvedPageUrl, targetId = null } = {}) {
176
+ export async function filterBrowserTargets(list, { validate = assertResolvedPageUrl, targetId = null, endpoint = null } = {}) {
177
177
  if (!Array.isArray(list)) return [];
178
178
  const candidates = [];
179
179
  for (const item of list) {
180
180
  if (!item || item.type !== 'page' || !item.webSocketDebuggerUrl) continue;
181
181
  if (targetId && item.id !== targetId) continue;
182
+ // Reject fabricated/stale CDP socket metadata *before* DNS validation.
183
+ // browser_tabs must never advertise a target that cannot pass chooseTarget.
184
+ if (endpoint) {
185
+ try { validatedTargetWebSocketUrl(item, endpoint); }
186
+ catch { continue; }
187
+ }
182
188
  candidates.push(item);
183
189
  if (candidates.length >= 1000) break;
184
190
  }
@@ -242,13 +248,18 @@ export async function listBrowserTargets(endpoint, { targetId = null, readOnlySi
242
248
  throw new Error(`${label} is unavailable at ${base.origin}. Read-only browser_tabs and browser_snapshot do not launch a browser. Use ui_snapshot and ui_find for the existing browser window; do not retry this CDP endpoint unchanged. Only browser_navigate(action=new_tab), when explicitly requested, may start a local isolated browser.${browserLaunchExitHint({ configured })}`);
243
249
  }
244
250
  // Retain the current bounded concurrent DNS/security checks for up to 1000 tabs.
245
- return filterBrowserTargets(list, { targetId, validate });
251
+ return filterBrowserTargets(list, { targetId, validate, endpoint:base });
246
252
  }
247
253
 
248
254
  export function validatedTargetWebSocketUrl(target, endpoint) {
249
255
  let socketUrl;
250
256
  try { socketUrl = new URL(String(target?.webSocketDebuggerUrl)); }
251
257
  catch { throw new Error('Browser returned an invalid CDP WebSocket URL'); }
258
+ const targetId = String(target?.id ?? '');
259
+ if (!targetId || targetId.length > 256 || socketUrl.username || socketUrl.password ||
260
+ socketUrl.search || socketUrl.hash) {
261
+ throw new Error('Browser returned an invalid CDP WebSocket target');
262
+ }
252
263
  if (!['ws:', 'wss:'].includes(socketUrl.protocol) || !isLoopbackHost(socketUrl.hostname)) {
253
264
  throw new Error('Browser CDP WebSocket target must be loopback-only');
254
265
  }
@@ -0,0 +1,81 @@
1
+ // Read-only, native CDP audits. Only numeric aggregates leave the device.
2
+ // Unlike DOM snapshots, these methods do not transmit page text to ReMCP.
3
+ const CONTROL_ROLES = new Set([
4
+ 'button','link','checkbox','radio','switch','textbox','searchbox','combobox',
5
+ 'menuitem','menuitemcheckbox','menuitemradio','tab','slider','spinbutton',
6
+ ]);
7
+ const REPORT_ROLES = new Set([
8
+ ...CONTROL_ROLES, 'heading','image','img','form','table','dialog','alert',
9
+ 'navigation','main','region','list','listitem','row','cell',
10
+ ]);
11
+ const finite = value => typeof value === 'number' && Number.isFinite(value);
12
+ const positive = value => finite(value) ? Math.max(0,Math.min(10_000_000, Math.round(value * 100) / 100)) : 0;
13
+
14
+ export function normalizeLayoutMetrics(result = {}) {
15
+ const viewport = result.cssLayoutViewport || {};
16
+ const visual = result.cssVisualViewport || {};
17
+ const content = result.cssContentSize || {};
18
+ const clientWidth = positive(viewport.clientWidth);
19
+ const clientHeight = positive(viewport.clientHeight);
20
+ const contentWidth = positive(content.width);
21
+ const contentHeight = positive(content.height);
22
+ return {
23
+ viewport: { width:clientWidth, height:clientHeight, page_x:positive(viewport.pageX), page_y:positive(viewport.pageY) },
24
+ visual_viewport: {
25
+ width:positive(visual.clientWidth), height:positive(visual.clientHeight),
26
+ scale:finite(visual.scale) ? Math.max(0,Math.min(100,visual.scale)) : null,
27
+ },
28
+ document: { width:contentWidth, height:contentHeight },
29
+ horizontal_overflow_px:Math.max(0,Math.ceil(contentWidth-clientWidth)),
30
+ vertical_scroll_px:Math.max(0,Math.ceil(contentHeight-clientHeight)),
31
+ note:'Native CSS-pixel layout metrics from the current page. Overflow measures the document bounds, not whether a particular element is visually clipped.',
32
+ };
33
+ }
34
+ export function summarizeAccessibilityTree(nodes, { depth = 6 } = {}) {
35
+ const bounded = (Array.isArray(nodes) ? nodes : []).slice(0, 5000);
36
+ const roles = {};
37
+ let ignored = 0, interactive = 0, unlabeledControls = 0, unnamedImages = 0;
38
+ let headings = 0;
39
+ const headingLevels = {};
40
+ for (const node of bounded) {
41
+ if (!node || typeof node !== 'object') continue;
42
+ if (node.ignored === true) { ignored++; continue; }
43
+ const role = String(node.role?.value || '').toLowerCase();
44
+ if (!REPORT_ROLES.has(role)) continue;
45
+ roles[role] = (roles[role] || 0) + 1;
46
+ const name = String(node.name?.value || '').trim();
47
+ if (CONTROL_ROLES.has(role)) {
48
+ interactive++;
49
+ // Anonymous count only: actual labels, element identifiers and URLs are never returned.
50
+ if (!name) unlabeledControls++;
51
+ }
52
+ if (role === 'image' || role === 'img') {
53
+ if (!name) unnamedImages++;
54
+ }
55
+ if (role === 'heading') {
56
+ headings++;
57
+ const level = node.properties?.find(item => item?.name === 'level')?.value?.value;
58
+ if (Number.isSafeInteger(level) && level >= 1 && level <= 6)
59
+ headingLevels[level] = (headingLevels[level] || 0) + 1;
60
+ }
61
+ }
62
+ return {
63
+ sampled_nodes:bounded.length, ignored_nodes:ignored, requested_depth:depth,
64
+ roles, interactive_controls:interactive, unlabeled_controls:unlabeledControls,
65
+ headings, heading_levels:headingLevels, unnamed_images:unnamedImages,
66
+ truncated: Array.isArray(nodes) && nodes.length > bounded.length,
67
+ note:'Bounded native accessibility tree sample. These are diagnostic candidates, not a WCAG compliance verdict. Nodes deeper than the requested depth are not inspected.',
68
+ };
69
+ }
70
+ export async function inspectNativeBrowserAudit(session, action) {
71
+ if (action === 'layout') {
72
+ const result = await session.send('Page.getLayoutMetrics');
73
+ return { action, layout:normalizeLayoutMetrics(result) };
74
+ }
75
+ if (action === 'accessibility') {
76
+ const depth = 6;
77
+ const result = await session.send('Accessibility.getFullAXTree',{depth});
78
+ return { action, accessibility:summarizeAccessibilityTree(result.nodes,{depth}) };
79
+ }
80
+ throw new Error('Unsupported native browser audit mode');
81
+ }
@@ -139,9 +139,42 @@ export function sensitiveAccessibilityDescendants(nodes) {
139
139
  return secret;
140
140
  }
141
141
 
142
+ // One viewport evaluation is enough for both dimensions, and may run while
143
+ // Chrome captures the screenshot. Selector capture already returned the viewport
144
+ // dimensions with the element bounds, so it needs no further CDP evaluation.
145
+ export async function captureViewportScreenshot(session, screenshotTarget = null) {
146
+ const dimensions = screenshotTarget
147
+ ? Promise.resolve({width:screenshotTarget.viewport_width, height:screenshotTarget.viewport_height})
148
+ : evaluate(session, '({width:innerWidth,height:innerHeight})').catch(() => null);
149
+ const image = session.send('Page.captureScreenshot', {
150
+ format:'png',fromSurface:true,captureBeyondViewport:false,
151
+ });
152
+ const [size,captured] = await Promise.all([dimensions,image]);
153
+ return {
154
+ captured,
155
+ viewport:{scope:'viewport',width:size?.width ?? null,height:size?.height ?? null},
156
+ };
157
+ }
158
+
159
+ // Enabling Accessibility is a per-CDP-socket state. Reuse the successful
160
+ // handshake for retained guarded sockets; a new socket always enables afresh.
161
+ const accessibilityReady = new WeakMap();
162
+ export async function ensureAccessibilityReady(session) {
163
+ let pending = accessibilityReady.get(session);
164
+ if (!pending) {
165
+ pending = Promise.resolve().then(() => session.send('Accessibility.enable'));
166
+ accessibilityReady.set(session,pending);
167
+ try { await pending; }
168
+ catch (error) {
169
+ if (accessibilityReady.get(session)===pending) accessibilityReady.delete(session);
170
+ throw error;
171
+ }
172
+ } else await pending;
173
+ }
174
+
142
175
  export async function browserSnapshot(args) {
143
176
  return withTarget(args, async (session, target) => {
144
- await session.send('Accessibility.enable');
177
+ await ensureAccessibilityReady(session);
145
178
  const { nodes = [] } = await session.send('Accessibility.getFullAXTree');
146
179
  const limit = clamp(args.max_nodes, 1500, 1, 10_000);
147
180
  const redacted = sensitiveAccessibilityDescendants(nodes);
@@ -206,27 +239,19 @@ export async function browserSnapshot(args) {
206
239
  const { original_scroll_x: _originalX, original_scroll_y: _originalY, ...reportedBounds } = bounds;
207
240
  payload.screenshot_target = { selector, ...reportedBounds };
208
241
  }
209
- payload.screenshot = {
210
- scope: 'viewport',
211
- width: await evaluate(session, 'innerWidth').catch(() => null),
212
- height: await evaluate(session, 'innerHeight').catch(() => null),
213
- };
214
- const result = jsonResult(payload);
215
- let captured;
242
+ let screenshot;
216
243
  try {
217
- captured = await session.send('Page.captureScreenshot', {
218
- format: 'png',
219
- fromSurface: true,
220
- captureBeyondViewport: false,
221
- });
244
+ screenshot = await captureViewportScreenshot(session, payload.screenshot_target);
222
245
  } finally {
223
246
  if (restoreScroll) {
224
247
  await evaluate(session, `window.scrollTo({left:${restoreScroll.x},top:${restoreScroll.y},behavior:'instant'})`).catch(() => {});
225
248
  }
226
249
  }
227
- const data = String(captured?.data || '');
250
+ const data = String(screenshot.captured?.data || '');
228
251
  if (!data) throw new Error('CDP returned no screenshot data');
229
252
  if (Buffer.byteLength(data, 'base64') > 8 * 1024 * 1024) throw new Error('Browser screenshot exceeds the 8 MiB inline image limit');
253
+ payload.screenshot = screenshot.viewport;
254
+ const result = jsonResult(payload);
230
255
  result.content.push({ type:'image', data, mimeType:'image/png' });
231
256
  return result;
232
257
  });
@@ -167,6 +167,7 @@ export const extendedToolDefinitions = [
167
167
  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() }), readOnlyOpen, browserHandlers.browser_find, ['browser_cdp']),
168
168
  define('browser_action', 'Browser element action', 'Click, focus, type/set a value, fill up to 20 form fields in one guarded call, select, scroll a page or scrollable element, scroll an element into view, 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','scroll_into_view','upload','press','set_viewport','fill_form','workflow']), selector:s('CSS selector for the target element; prefer the reusable selector returned by browser_find.'), steps:{type:'array',minItems:1,maxItems:12,description:'Workflow on one page: explicit CSS actions and bounded waits, max 12 seconds. Never silently submits; click must be explicit.',items:{type:'object',additionalProperties:false,properties:{action:e(['fill_form','set_value','select','focus','click','wait_for']),selector:s('CSS selector for action or wait_for.'),value:{type:['string','number','boolean']},fields:{type:'array',description:'CSS-selected field edits within the workflow step; no implicit form submission.',minItems:1,maxItems:20,items:{type:'object',additionalProperties:false,properties:{selector:s(),value:{type:['string','number','boolean']}},required:['selector','value']}},condition:e(['selector','text','url_contains','load']),text:s('Text or URL fragment for wait condition.'),timeout_ms:n('Wait timeout 100..4000 ms; default 1500.')},required:['action']}}, fields:{type:'array',minItems:1,maxItems:20,items:{type:'object',additionalProperties:false,properties:{selector:s('CSS selector.'),value:{type:['string','boolean','number'],description:'New field value; not returned in results.'}},required:['selector','value']},description:'fill_form: 1–20 CSS form fields in one CDP call. Does not submit.'}, browser_text:s('Visible text used only to locate a DOM element; use this instead of overloading text for action=type.'), text:s('For action=type, Unicode text to insert. For non-type element actions without selector/browser_text, visible text may identify the target.'), clear:b('For action=type, clear the current value before inserting text.'), value:s('Value for action=set_value; also accepted as a legacy type payload when text is used as the locator.'), text_value:s('Compatibility alias for the type/set_value text value.'), option:s(), delta_x:n('Horizontal scroll delta; positive means right.'), delta_y:n('Vertical scroll delta; positive means down.'), delta:n('Alias for delta_y.'), direction:e(['up','down','left','right']), wheel_times:n('Number of 120-unit wheel steps for direction; 1..50.'), 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']),
169
169
  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.') }), readOnlyOpen, browserHandlers.browser_wait, ['browser_cdp']),
170
+ define('browser_devtools', 'Browser DevTools diagnostics', 'Use this to read a bounded live sample of Chrome Console or Network events, or native Performance metrics through CDP; excludes cookies, request bodies, response bodies and authentication headers.', o({ ...browserTarget, action:e(['console','network','performance','layout','accessibility'],'Diagnostics mode. Layout reports CSS-pixel viewport/overflow; accessibility reports anonymized role counts and unlabeled controls.'), sample_ms:n('Live event capture period 250–5000 ms; default 1500. Ignored for performance, layout and accessibility.'), max_events:n('Maximum 1–100 events retained; default 50.') }, ['action']), readOnlyOpen, browserHandlers.browser_devtools, ['browser_cdp']),
170
171
  define('browser_evaluate', 'Evaluate browser JavaScript', 'Evaluate JavaScript in a selected debuggable browser page and return its serializable value. This is a powerful escape hatch for page-specific automation.', o({ ...browserTarget, expression:s('JavaScript expression.'), await_promise:b('Await a returned Promise; default true.') }, ['expression']), openMutating, browserHandlers.browser_evaluate, ['browser_evaluate']),
171
172
 
172
173
  define('service', 'Service control', 'List, inspect, start, stop, or restart operating-system services using Service Control Manager, launchctl, or systemd. Mutating actions pass through ReMCP command policy.', o({ action:e(['list','status','start','stop','restart']), name:s('Service/unit/launchd label.'), scope:e(['user','system']) }, ['action']), mutating, diagnosticHandlers.service, ['services']),