Support multi window scenarios in vscode mcp server (#289250)

* Add window management tools for multi-window support

* Refactor window management tools to remove title references and simplify responses

* Refactor PlaywrightDriver to use readonly page property and remove _currentPage references

* Update test/mcp/src/automationTools/windows.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Fix URL matching and CDP session handling in multi-window support (#289261)

* Initial plan

* Address code review feedback: improve URL matching and CDP session handling

- Improve URL matching in switchToWindow to prefer exact matches before substring matches
- Clear CDP session when switching windows to prevent using stale sessions
- Add comprehensive documentation explaining the matching strategy and CDP session behavior

Co-authored-by: rebornix <876920+rebornix@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: rebornix <876920+rebornix@users.noreply.github.com>

* Add automation tools for window interactions and update multiplex server configuration

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: rebornix <876920+rebornix@users.noreply.github.com>
This commit is contained in:
Peng Lyu
2026-02-02 16:11:12 -08:00
committed by GitHub
parent d6aef2740d
commit 5366853409
4 changed files with 717 additions and 6 deletions
+278 -2
View File
@@ -66,7 +66,7 @@ export class PlaywrightDriver {
constructor(
private readonly application: playwright.Browser | playwright.ElectronApplication,
private readonly context: playwright.BrowserContext,
private readonly page: playwright.Page,
private _currentPage: playwright.Page,
private readonly serverProcess: ChildProcess | undefined,
private readonly whenLoaded: Promise<unknown>,
private readonly options: LaunchOptions
@@ -77,8 +77,284 @@ export class PlaywrightDriver {
return this.context;
}
private get page(): playwright.Page {
return this._currentPage;
}
get currentPage(): playwright.Page {
return this.page;
return this._currentPage;
}
/**
* Get all open windows/pages.
* For Electron apps, returns all Electron windows.
* For browser contexts, returns all pages.
*/
getAllWindows(): playwright.Page[] {
if ('windows' in this.application) {
return (this.application as playwright.ElectronApplication).windows();
}
return this.context.pages();
}
/**
* Switch to a different window by index or URL pattern.
* @param indexOrUrl - Window index (0-based) or a string to match against the URL.
* When using a string, it first tries to find an exact URL match,
* then falls back to finding the first URL that contains the pattern.
* @returns The switched-to page, or undefined if not found
* @note When switching windows, any existing CDP session will be cleared since it
* remains attached to the previous page and cannot be used with the new page.
*/
switchToWindow(indexOrUrl: number | string): playwright.Page | undefined {
const windows = this.getAllWindows();
if (typeof indexOrUrl === 'number') {
if (indexOrUrl >= 0 && indexOrUrl < windows.length) {
this._currentPage = windows[indexOrUrl];
// Clear CDP session as it's attached to the previous page
this._cdpSession = undefined;
return this._currentPage;
}
} else {
// First try exact match, then fall back to substring match
let found = windows.find(w => w.url() === indexOrUrl);
if (!found) {
found = windows.find(w => w.url().includes(indexOrUrl));
}
if (found) {
this._currentPage = found;
// Clear CDP session as it's attached to the previous page
this._cdpSession = undefined;
return this._currentPage;
}
}
return undefined;
}
/**
* Get information about all windows.
*/
getWindowsInfo(): { index: number; url: string; isCurrent: boolean }[] {
const windows = this.getAllWindows();
return windows.map((p, index) => ({
index,
url: p.url(),
isCurrent: p === this._currentPage
}));
}
/**
* Take a screenshot of the current window.
* @param fullPage - Whether to capture the full scrollable page
* @returns Screenshot as a Buffer
*/
async screenshotBuffer(fullPage: boolean = false): Promise<Buffer> {
return await this.page.screenshot({
type: 'png',
fullPage
});
}
/**
* Get the accessibility snapshot of the current window.
*/
async getAccessibilitySnapshot(): Promise<playwright.Accessibility['snapshot'] extends () => Promise<infer T> ? T : never> {
return await this.page.accessibility.snapshot();
}
/**
* Click on an element using CSS selector with options.
*/
async clickSelector(selector: string, options?: { button?: 'left' | 'right' | 'middle'; clickCount?: number }): Promise<void> {
await this.page.click(selector, {
button: options?.button ?? 'left',
clickCount: options?.clickCount ?? 1
});
}
/**
* Type text into an element.
* @param selector - CSS selector for the element
* @param text - Text to type
* @param slowly - Whether to type character by character (triggers key events)
*/
async typeText(selector: string, text: string, slowly: boolean = false): Promise<void> {
if (slowly) {
await this.page.type(selector, text, { delay: 50 });
} else {
await this.page.fill(selector, text);
}
}
/**
* Evaluate a JavaScript expression in the current window.
*/
async evaluateExpression<T = unknown>(expression: string): Promise<T> {
return await this.page.evaluate(expression) as T;
}
/**
* Get information about elements matching a selector.
*/
async getLocatorInfo(selector: string, action?: 'count' | 'textContent' | 'innerHTML' | 'boundingBox' | 'isVisible'): Promise<
number | string[] | { x: number; y: number; width: number; height: number } | null | boolean | { count: number; firstVisible: boolean }
> {
const locator = this.page.locator(selector);
switch (action) {
case 'count':
return await locator.count();
case 'textContent':
return await locator.allTextContents();
case 'innerHTML':
return await locator.allInnerTexts();
case 'boundingBox':
return await locator.first().boundingBox();
case 'isVisible':
return await locator.first().isVisible();
default:
return {
count: await locator.count(),
firstVisible: await locator.first().isVisible().catch(() => false)
};
}
}
/**
* Wait for an element to reach a specific state.
*/
async waitForElement(selector: string, options?: { state?: 'attached' | 'detached' | 'visible' | 'hidden'; timeout?: number }): Promise<void> {
await this.page.waitForSelector(selector, {
state: options?.state ?? 'visible',
timeout: options?.timeout ?? 30000
});
}
/**
* Hover over an element.
*/
async hoverSelector(selector: string): Promise<void> {
await this.page.hover(selector);
}
/**
* Drag from one element to another.
*/
async dragSelector(sourceSelector: string, targetSelector: string): Promise<void> {
await this.page.dragAndDrop(sourceSelector, targetSelector);
}
/**
* Press a key or key combination.
*/
async pressKey(key: string): Promise<void> {
await this.page.keyboard.press(key);
}
/**
* Move mouse to a specific position.
*/
async mouseMove(x: number, y: number): Promise<void> {
await this.page.mouse.move(x, y);
}
/**
* Click at a specific position.
*/
async mouseClick(x: number, y: number, options?: { button?: 'left' | 'right' | 'middle'; clickCount?: number }): Promise<void> {
await this.page.mouse.click(x, y, {
button: options?.button ?? 'left',
clickCount: options?.clickCount ?? 1
});
}
/**
* Drag from one position to another.
*/
async mouseDrag(startX: number, startY: number, endX: number, endY: number): Promise<void> {
await this.page.mouse.move(startX, startY);
await this.page.mouse.down();
await this.page.mouse.move(endX, endY);
await this.page.mouse.up();
}
/**
* Select an option in a dropdown.
*/
async selectOption(selector: string, value: string | string[]): Promise<string[]> {
return await this.page.selectOption(selector, value);
}
/**
* Fill multiple form fields at once.
*/
async fillForm(fields: { selector: string; value: string }[]): Promise<void> {
for (const field of fields) {
await this.page.fill(field.selector, field.value);
}
}
/**
* Get console messages from the current window.
*/
async getConsoleMessages(): Promise<{ type: string; text: string }[]> {
const messages = await this.page.consoleMessages();
return messages.map(m => ({
type: m.type(),
text: m.text()
}));
}
/**
* Wait for text to appear, disappear, or a specified time to pass.
*/
async waitForText(options: { text?: string; textGone?: string; timeout?: number }): Promise<void> {
const { text, textGone, timeout = 30000 } = options;
if (text) {
await this.page.getByText(text).first().waitFor({ state: 'visible', timeout });
}
if (textGone) {
await this.page.getByText(textGone).first().waitFor({ state: 'hidden', timeout });
}
}
/**
* Wait for a specified time in milliseconds.
*/
async waitForTime(ms: number): Promise<void> {
await new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Verify an element is visible.
*/
async verifyElementVisible(selector: string): Promise<boolean> {
try {
await this.page.locator(selector).first().waitFor({ state: 'visible', timeout: 5000 });
return true;
} catch {
return false;
}
}
/**
* Verify text is visible on the page.
*/
async verifyTextVisible(text: string): Promise<boolean> {
try {
await this.page.getByText(text).first().waitFor({ state: 'visible', timeout: 5000 });
return true;
} catch {
return false;
}
}
/**
* Get the value of an input element.
*/
async getInputValue(selector: string): Promise<string> {
return await this.page.inputValue(selector);
}
async startTracing(name?: string): Promise<void> {
+6 -1
View File
@@ -25,6 +25,7 @@ import { applyLocalizationTools } from './localization.js';
import { applyTaskTools } from './task.js';
import { applyProfilerTools } from './profiler.js';
import { applyChatTools } from './chat.js';
import { applyWindowTools } from './windows.js';
import { ApplicationService } from '../application';
/**
@@ -93,6 +94,9 @@ export function applyAllTools(server: McpServer, appService: ApplicationService)
// Chat Tools
tools = tools.concat(applyChatTools(server, appService));
// Window Management Tools (for multi-window support)
tools = tools.concat(applyWindowTools(server, appService));
// Return all registered tools
return tools;
}
@@ -117,5 +121,6 @@ export {
applyLocalizationTools,
applyTaskTools,
applyProfilerTools,
applyChatTools
applyChatTools,
applyWindowTools
};
+387
View File
@@ -0,0 +1,387 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { ApplicationService } from '../application';
/**
* Create a standardized text response for window tools
*/
function textResponse(text: string) {
return {
content: [{ type: 'text' as const, text }]
};
}
/**
* Window Management Tools for multi-window support.
* These tools are thin wrappers around PlaywrightDriver methods.
*/
export function applyWindowTools(server: McpServer, appService: ApplicationService): RegisteredTool[] {
const tools: RegisteredTool[] = [];
tools.push(server.tool(
'vscode_automation_list_windows',
'List all open VS Code windows with their index and URL',
async () => {
const app = await appService.getOrCreateApplication();
const windowInfo = app.code.driver.getWindowsInfo();
return textResponse(`Open windows (${windowInfo.length}):\n${JSON.stringify(windowInfo, null, 2)}`);
}
));
tools.push(server.tool(
'vscode_automation_switch_window',
'Switch to a different VS Code window by index or URL pattern (e.g., "agent.html")',
{
indexOrUrl: z.union([z.number(), z.string()]).describe('Window index (0-based) or URL pattern to match (e.g., "agent.html", "workbench")')
},
async ({ indexOrUrl }) => {
const app = await appService.getOrCreateApplication();
const switched = app.code.driver.switchToWindow(indexOrUrl);
if (switched) {
return textResponse(`Switched to window (URL: ${switched.url()})`);
}
const windowInfo = app.code.driver.getWindowsInfo();
const availableWindows = windowInfo.map(w => ` ${w.index}: ${w.url}`).join('\n');
return textResponse(`Failed to switch window. Window not found for: ${indexOrUrl}\n\nAvailable windows:\n${availableWindows}`);
}
));
tools.push(server.tool(
'vscode_automation_get_current_window',
'Get information about the currently active window',
async () => {
const app = await appService.getOrCreateApplication();
const driver = app.code.driver;
const windowInfo = driver.getWindowsInfo();
const current = windowInfo.find(w => w.isCurrent);
return textResponse(`Current window:\nIndex: ${current?.index ?? -1}\nURL: ${current?.url ?? 'Unknown'}`);
}
));
tools.push(server.tool(
'vscode_automation_window_screenshot',
'Take a screenshot of the current window (respects the window set by vscode_automation_switch_window)',
{
fullPage: z.boolean().optional().describe('When true, takes a screenshot of the full scrollable page')
},
async ({ fullPage }) => {
const app = await appService.getOrCreateApplication();
const driver = app.code.driver;
const screenshotBuffer = await driver.screenshotBuffer(fullPage ?? false);
const url = driver.currentPage.url();
return {
content: [
{ type: 'text' as const, text: `Screenshot (URL: ${url})` },
{ type: 'image' as const, data: screenshotBuffer.toString('base64'), mimeType: 'image/png' }
]
};
}
));
tools.push(server.tool(
'vscode_automation_window_snapshot',
'Capture accessibility snapshot of the current window. Returns the page structure with element references that can be used for interactions.',
async () => {
const app = await appService.getOrCreateApplication();
const driver = app.code.driver;
const snapshot = await driver.getAccessibilitySnapshot();
const url = driver.currentPage.url();
return textResponse(`Page snapshot (URL: ${url}):\n\n${JSON.stringify(snapshot, null, 2)}`);
}
));
tools.push(server.tool(
'vscode_automation_window_click',
'Click on an element in the current window using a CSS selector',
{
selector: z.string().describe('CSS selector for the element to click'),
button: z.enum(['left', 'right', 'middle']).optional().describe('Mouse button to click'),
clickCount: z.number().optional().describe('Number of clicks (1 for single, 2 for double)')
},
async ({ selector, button, clickCount }) => {
const app = await appService.getOrCreateApplication();
const driver = app.code.driver;
await driver.clickSelector(selector, { button, clickCount });
return textResponse(`Clicked "${selector}"`);
}
));
tools.push(server.tool(
'vscode_automation_window_type',
'Type text into an element in the current window',
{
selector: z.string().describe('CSS selector for the element to type into'),
text: z.string().describe('Text to type'),
slowly: z.boolean().optional().describe('Whether to type one character at a time (useful for triggering key handlers)')
},
async ({ selector, text, slowly }) => {
const app = await appService.getOrCreateApplication();
const driver = app.code.driver;
await driver.typeText(selector, text, slowly ?? false);
return textResponse(`Typed "${text}" into "${selector}"`);
}
));
tools.push(server.tool(
'vscode_automation_window_evaluate',
'Evaluate JavaScript in the current window',
{
expression: z.string().describe('JavaScript expression to evaluate')
},
async ({ expression }) => {
const app = await appService.getOrCreateApplication();
const driver = app.code.driver;
const result = await driver.evaluateExpression(expression);
return textResponse(`Result:\n${JSON.stringify(result, null, 2)}`);
}
));
tools.push(server.tool(
'vscode_automation_window_locator',
'Get information about elements matching a selector in the current window',
{
selector: z.string().describe('CSS selector to find elements'),
action: z.enum(['count', 'textContent', 'innerHTML', 'boundingBox', 'isVisible']).optional().describe('Action to perform on matched elements')
},
async ({ selector, action }) => {
const app = await appService.getOrCreateApplication();
const driver = app.code.driver;
const result = await driver.getLocatorInfo(selector, action);
return textResponse(`Locator "${selector}":\n${JSON.stringify(result, null, 2)}`);
}
));
tools.push(server.tool(
'vscode_automation_window_wait_for_selector',
'Wait for an element to appear in the current window',
{
selector: z.string().describe('CSS selector to wait for'),
state: z.enum(['attached', 'detached', 'visible', 'hidden']).optional().describe('State to wait for'),
timeout: z.number().optional().describe('Timeout in milliseconds')
},
async ({ selector, state, timeout }) => {
const app = await appService.getOrCreateApplication();
const driver = app.code.driver;
await driver.waitForElement(selector, { state, timeout });
return textResponse(`Element "${selector}" is now ${state ?? 'visible'}`);
}
));
tools.push(server.tool(
'vscode_automation_window_hover',
'Hover over an element in the current window',
{
selector: z.string().describe('CSS selector for the element to hover over')
},
async ({ selector }) => {
const app = await appService.getOrCreateApplication();
const driver = app.code.driver;
await driver.hoverSelector(selector);
return textResponse(`Hovered over "${selector}"`);
}
));
tools.push(server.tool(
'vscode_automation_window_drag',
'Drag from one element to another in the current window',
{
sourceSelector: z.string().describe('CSS selector for the source element'),
targetSelector: z.string().describe('CSS selector for the target element')
},
async ({ sourceSelector, targetSelector }) => {
const app = await appService.getOrCreateApplication();
const driver = app.code.driver;
await driver.dragSelector(sourceSelector, targetSelector);
return textResponse(`Dragged from "${sourceSelector}" to "${targetSelector}"`);
}
));
tools.push(server.tool(
'vscode_automation_window_press_key',
'Press a key or key combination in the current window',
{
key: z.string().describe('Key to press (e.g., "Enter", "Tab", "Control+c", "Meta+v")')
},
async ({ key }) => {
const app = await appService.getOrCreateApplication();
const driver = app.code.driver;
await driver.pressKey(key);
return textResponse(`Pressed key "${key}"`);
}
));
tools.push(server.tool(
'vscode_automation_window_mouse_move',
'Move mouse to a specific position in the current window',
{
x: z.number().describe('X coordinate'),
y: z.number().describe('Y coordinate')
},
async ({ x, y }) => {
const app = await appService.getOrCreateApplication();
const driver = app.code.driver;
await driver.mouseMove(x, y);
return textResponse(`Moved mouse to (${x}, ${y})`);
}
));
tools.push(server.tool(
'vscode_automation_window_mouse_click',
'Click at a specific position in the current window',
{
x: z.number().describe('X coordinate'),
y: z.number().describe('Y coordinate'),
button: z.enum(['left', 'right', 'middle']).optional().describe('Mouse button to click'),
clickCount: z.number().optional().describe('Number of clicks (1 for single, 2 for double)')
},
async ({ x, y, button, clickCount }) => {
const app = await appService.getOrCreateApplication();
const driver = app.code.driver;
await driver.mouseClick(x, y, { button, clickCount });
return textResponse(`Clicked at (${x}, ${y})`);
}
));
tools.push(server.tool(
'vscode_automation_window_mouse_drag',
'Drag from one position to another in the current window',
{
startX: z.number().describe('Starting X coordinate'),
startY: z.number().describe('Starting Y coordinate'),
endX: z.number().describe('Ending X coordinate'),
endY: z.number().describe('Ending Y coordinate')
},
async ({ startX, startY, endX, endY }) => {
const app = await appService.getOrCreateApplication();
const driver = app.code.driver;
await driver.mouseDrag(startX, startY, endX, endY);
return textResponse(`Dragged from (${startX}, ${startY}) to (${endX}, ${endY})`);
}
));
tools.push(server.tool(
'vscode_automation_window_select_option',
'Select an option in a dropdown in the current window',
{
selector: z.string().describe('CSS selector for the select element'),
value: z.union([z.string(), z.array(z.string())]).describe('Value(s) to select')
},
async ({ selector, value }) => {
const app = await appService.getOrCreateApplication();
const driver = app.code.driver;
const selected = await driver.selectOption(selector, value);
return textResponse(`Selected "${selected.join(', ')}" in "${selector}"`);
}
));
tools.push(server.tool(
'vscode_automation_window_fill_form',
'Fill multiple form fields at once in the current window',
{
fields: z.array(z.object({
selector: z.string().describe('CSS selector for the form field'),
value: z.string().describe('Value to fill')
})).describe('Array of fields to fill')
},
async ({ fields }) => {
const app = await appService.getOrCreateApplication();
const driver = app.code.driver;
await driver.fillForm(fields);
return textResponse(`Filled ${fields.length} form field(s)`);
}
));
tools.push(server.tool(
'vscode_automation_window_console_messages',
'Get console messages from the current window',
async () => {
const app = await appService.getOrCreateApplication();
const driver = app.code.driver;
const messages = await driver.getConsoleMessages();
return textResponse(`Console messages (${messages.length}):\n${JSON.stringify(messages, null, 2)}`);
}
));
tools.push(server.tool(
'vscode_automation_window_wait_for_text',
'Wait for text to appear or disappear in the current window',
{
text: z.string().optional().describe('Text to wait for to appear'),
textGone: z.string().optional().describe('Text to wait for to disappear'),
timeout: z.number().optional().describe('Timeout in milliseconds (default: 30000)')
},
async ({ text, textGone, timeout }) => {
const app = await appService.getOrCreateApplication();
const driver = app.code.driver;
await driver.waitForText({ text, textGone, timeout });
return textResponse(`Waited for ${text ? `"${text}" to appear` : ''}${textGone ? `"${textGone}" to disappear` : ''}`);
}
));
tools.push(server.tool(
'vscode_automation_window_wait_for_time',
'Wait for a specified time in the current window',
{
seconds: z.number().describe('Time to wait in seconds')
},
async ({ seconds }) => {
const app = await appService.getOrCreateApplication();
const driver = app.code.driver;
await driver.waitForTime(seconds * 1000);
return textResponse(`Waited for ${seconds} second(s)`);
}
));
tools.push(server.tool(
'vscode_automation_window_verify_element_visible',
'Verify an element is visible in the current window',
{
selector: z.string().describe('CSS selector for the element to verify')
},
async ({ selector }) => {
const app = await appService.getOrCreateApplication();
const driver = app.code.driver;
const isVisible = await driver.verifyElementVisible(selector);
return textResponse(isVisible ? `✓ Element "${selector}" is visible` : `✗ Element "${selector}" is NOT visible`);
}
));
tools.push(server.tool(
'vscode_automation_window_verify_text_visible',
'Verify text is visible in the current window',
{
text: z.string().describe('Text to verify is visible')
},
async ({ text }) => {
const app = await appService.getOrCreateApplication();
const driver = app.code.driver;
const isVisible = await driver.verifyTextVisible(text);
return textResponse(isVisible ? `✓ Text "${text}" is visible` : `✗ Text "${text}" is NOT visible`);
}
));
tools.push(server.tool(
'vscode_automation_window_get_input_value',
'Get the value of an input element in the current window',
{
selector: z.string().describe('CSS selector for the input element')
},
async ({ selector }) => {
const app = await appService.getOrCreateApplication();
const driver = app.code.driver;
const value = await driver.getInputValue(selector);
return textResponse(`Input "${selector}" value: "${value}"`);
}
));
return tools;
}
+46 -3
View File
@@ -48,11 +48,54 @@ export async function getServer(): Promise<Server> {
multiplexServer.addSubServer({
subServer: playwrightClient,
excludeTools: [
// The page will always be opened in the context of the application,
// so navigation and tab management is not needed.
// Playwright MCP doesn't properly support Electron's multi-window model.
// It uses browserContext.pages() which doesn't track Electron windows correctly.
// We provide vscode_automation_window_* alternatives that use ElectronApplication.windows().
// Navigation not needed - VS Code opens its own windows
'browser_navigate',
'browser_navigate_back',
'browser_tabs'
'browser_tabs',
// Page interaction tools - replaced by vscode_automation_window_*
'browser_click', // → vscode_automation_window_click
'browser_type', // → vscode_automation_window_type
'browser_hover', // → vscode_automation_window_hover
'browser_drag', // → vscode_automation_window_drag
'browser_select_option', // → vscode_automation_window_select_option
'browser_fill_form', // → vscode_automation_window_fill_form
'browser_press_key', // → vscode_automation_window_press_key
// Mouse operations - replaced by vscode_automation_window_mouse_*
'browser_mouse_move_xy', // → vscode_automation_window_mouse_move
'browser_mouse_click_xy', // → vscode_automation_window_mouse_click
'browser_mouse_drag_xy', // → vscode_automation_window_mouse_drag
// Content capture - replaced by vscode_automation_window_*
'browser_snapshot', // → vscode_automation_window_snapshot
'browser_take_screenshot', // → vscode_automation_window_screenshot
'browser_evaluate', // → vscode_automation_window_evaluate
// Console/debugging - replaced by vscode_automation_window_*
'browser_console_messages', // → vscode_automation_window_console_messages
// Wait/timing - replaced by vscode_automation_window_*
'browser_wait_for', // → vscode_automation_window_wait_for_text / wait_for_time
// Verification - replaced by vscode_automation_window_*
'browser_verify_element_visible', // → vscode_automation_window_verify_element_visible
'browser_verify_text_visible', // → vscode_automation_window_verify_text_visible
'browser_verify_list_visible', // (no direct replacement - use multiple verify_text_visible)
'browser_verify_value', // → vscode_automation_window_get_input_value
// Other page-dependent tools (not typically needed for VS Code testing)
'browser_close',
'browser_resize',
'browser_network_requests',
'browser_file_upload',
'browser_handle_dialog',
'browser_pdf_save',
'browser_generate_locator'
]
});
multiplexServer.sendToolListChanged();