Get rid of dependency on playwright-mcp (#292432)

This commit is contained in:
Tyler James Leonhardt
2026-02-02 23:16:17 -08:00
committed by GitHub
parent 2c2c45a9b5
commit c8d90ab45f
10 changed files with 82 additions and 654 deletions

View File

@@ -3,13 +3,12 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as playwright from 'playwright';
import { getDevElectronPath, Quality, ConsoleLogger, FileLogger, Logger, MultiLogger, getBuildElectronPath, getBuildVersion, measureAndLog, Application } from '../../automation';
import * as path from 'path';
import * as fs from 'fs';
import * as os from 'os';
import * as vscodetest from '@vscode/test-electron';
import { createApp, retry } from './utils';
import { createApp, retry, parseVersion } from './utils';
import { opts } from './options';
const rootPath = path.join(__dirname, '..', '..', '..');
@@ -61,11 +60,6 @@ function fail(errorMessage): void {
let quality: Quality;
let version: string | undefined;
function parseVersion(version: string): { major: number; minor: number; patch: number } {
const [, major, minor, patch] = /^(\d+)\.(\d+)\.(\d+)/.exec(version)!;
return { major: parseInt(major), minor: parseInt(minor), patch: parseInt(patch) };
}
function parseQuality(): Quality {
if (process.env.VSCODE_DEV === '1') {
return Quality.Dev;
@@ -245,10 +239,6 @@ export async function getApplication({ recordVideo, workspacePath }: { recordVid
await setup();
const application = createApp({
// Pass the alpha version of Playwright down... This is a hack since Playwright MCP
// doesn't play nice with Playwright Test: https://github.com/microsoft/playwright-mcp/issues/917
// eslint-disable-next-line local/code-no-any-casts
playwright: playwright as any,
quality,
version: parseVersion(version ?? '0.0.0'),
codePath: opts.build,

View File

@@ -18,7 +18,7 @@ function textResponse(text: string) {
/**
* Window Management Tools for multi-window support.
* These tools are thin wrappers around PlaywrightDriver methods.
* These tools provide Playwright-based window interactions through the automation driver.
*/
export function applyWindowTools(server: McpServer, appService: ApplicationService): RegisteredTool[] {
const tools: RegisteredTool[] = [];

View File

@@ -1,81 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { EventStore } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
/**
* Simple in-memory implementation of the EventStore interface for resumability
* This is primarily intended for examples and testing, not for production use
* where a persistent storage solution would be more appropriate.
*/
export class InMemoryEventStore implements EventStore {
private events: Map<string, { streamId: string; message: JSONRPCMessage }> = new Map();
/**
* Generates a unique event ID for a given stream ID
*/
private generateEventId(streamId: string): string {
return `${streamId}_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`;
}
/**
* Extracts the stream ID from an event ID
*/
private getStreamIdFromEventId(eventId: string): string {
const parts = eventId.split('_');
return parts.length > 0 ? parts[0] : '';
}
/**
* Stores an event with a generated event ID
* Implements EventStore.storeEvent
*/
async storeEvent(streamId: string, message: JSONRPCMessage): Promise<string> {
const eventId = this.generateEventId(streamId);
this.events.set(eventId, { streamId, message });
return eventId;
}
/**
* Replays events that occurred after a specific event ID
* Implements EventStore.replayEventsAfter
*/
async replayEventsAfter(lastEventId: string,
{ send }: { send: (eventId: string, message: JSONRPCMessage) => Promise<void> }
): Promise<string> {
if (!lastEventId || !this.events.has(lastEventId)) {
return '';
}
// Extract the stream ID from the event ID
const streamId = this.getStreamIdFromEventId(lastEventId);
if (!streamId) {
return '';
}
let foundLastEvent = false;
// Sort events by eventId for chronological ordering
const sortedEvents = [...this.events.entries()].sort((a, b) => a[0].localeCompare(b[0]));
for (const [eventId, { streamId: eventStreamId, message }] of sortedEvents) {
// Only include events from the same stream
if (eventStreamId !== streamId) {
continue;
}
// Start sending events after we find the lastEventId
if (eventId === lastEventId) {
foundLastEvent = true;
continue;
}
if (foundLastEvent) {
await send(eventId, message);
}
}
return streamId;
}
}

View File

@@ -1,198 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { JSONRPCMessage, MessageExtraInfo } from '@modelcontextprotocol/sdk/types.js';
import { Transport, TransportSendOptions } from '@modelcontextprotocol/sdk/shared/transport.js';
import { Duplex } from 'stream';
/**
* Creates a pair of in-memory transports that are connected to each other.
* Messages sent on one transport are received on the other transport.
* This uses actual Node.js streams to simulate real stdio behavior.
*
* @returns A tuple of [serverTransport, clientTransport] where the server
* and client can communicate with each other through these transports.
*/
export function createInMemoryTransportPair(): [InMemoryTransport, InMemoryTransport] {
// Create two duplex streams that are connected to each other
const serverStream = new Duplex({ objectMode: true, allowHalfOpen: false });
const clientStream = new Duplex({ objectMode: true, allowHalfOpen: false });
// Cross-connect the streams: server writes go to client reads and vice versa
// Server stream implementation
serverStream._write = (chunk: any, encoding: BufferEncoding, callback: (error?: Error | null) => void) => {
// When server writes, client should receive it
clientStream.push(chunk);
callback();
};
serverStream._read = () => {
// Signal that we're ready to read - no action needed for cross-connected streams
};
// Client stream implementation
clientStream._write = (chunk: any, encoding: BufferEncoding, callback: (error?: Error | null) => void) => {
// When client writes, server should receive it
serverStream.push(chunk);
callback();
};
clientStream._read = () => {
// Signal that we're ready to read - no action needed for cross-connected streams
};
// Handle stream ending properly
serverStream.on('end', () => {
if (!clientStream.destroyed) {
clientStream.push(null);
}
});
clientStream.on('end', () => {
if (!serverStream.destroyed) {
serverStream.push(null);
}
});
const serverTransport = new InMemoryTransport(serverStream);
const clientTransport = new InMemoryTransport(clientStream);
return [serverTransport, clientTransport];
}
/**
* An in-memory transport implementation that allows two MCP endpoints to communicate
* using Node.js streams, similar to how StdioTransport works. This provides more
* realistic behavior than direct message passing.
*/
export class InMemoryTransport implements Transport {
private _stream: Duplex;
private _started = false;
private _closed = false;
private _sessionId: string;
// Transport callbacks
public onclose?: () => void;
public onerror?: (error: Error) => void;
public onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void;
constructor(stream: Duplex) {
this._stream = stream;
this._sessionId = `memory-${Math.random().toString(36).substring(2, 15)}`;
// Set up stream event handlers
this._stream.on('data', (data: any) => {
if (this._started && !this._closed) {
try {
// Expect data to be a JSON-RPC message object
const message = typeof data === 'string' ? JSON.parse(data) : data;
const extra: MessageExtraInfo | undefined = undefined;
this.onmessage?.(message, extra);
} catch (error) {
this.onerror?.(error instanceof Error ? error : new Error(String(error)));
}
}
});
this._stream.on('error', (error: Error) => {
this.onerror?.(error);
});
this._stream.on('end', () => {
this._closed = true;
this.onclose?.();
});
this._stream.on('close', () => {
this._closed = true;
this.onclose?.();
});
}
/**
* Starts the transport. This must be called before sending or receiving messages.
*/
async start(): Promise<void> {
if (this._started) {
return;
}
if (this._closed) {
throw new Error('Cannot start a closed transport');
}
this._started = true;
}
/**
* Sends a JSON-RPC message through the stream.
*/
async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise<void> {
if (!this._started) {
throw new Error('Transport not started');
}
if (this._closed) {
throw new Error('Transport is closed');
}
// Write the message to the stream - similar to how StdioTransport works
return new Promise<void>((resolve, reject) => {
this._stream.write(message, (error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
});
}
/**
* Closes the transport and the underlying stream.
*/
async close(): Promise<void> {
if (this._closed) {
return;
}
this._closed = true;
// End the stream, which will trigger the 'end' event on the peer
return new Promise<void>((resolve) => {
this._stream.end(() => {
resolve();
});
});
}
/**
* Gets the session ID for this transport connection.
*/
get sessionId(): string {
return this._sessionId;
}
/**
* Sets the protocol version (optional implementation).
*/
setProtocolVersion?(version: string): void {
// No-op for in-memory transport
}
/**
* Checks if the transport is currently connected and started.
*/
get isConnected(): boolean {
return this._started && !this._closed && !this._stream.destroyed;
}
/**
* Checks if the transport has been closed.
*/
get isClosed(): boolean {
return this._closed || this._stream.destroyed;
}
}

View File

@@ -1,286 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Server, ServerOptions } from '@modelcontextprotocol/sdk/server/index.js';
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import { Implementation, ListToolsRequestSchema, CallToolRequestSchema, ListToolsResult, Tool, CallToolResult, McpError, ErrorCode, CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';
import { getServer as getAutomationServer } from './automation';
import { getServer as getPlaywrightServer } from './playwright';
import { ApplicationService } from './application';
import { createInMemoryTransportPair } from './inMemoryTransport';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { Application } from '../../automation';
import { opts } from './options';
interface SubServerConfig {
subServer: Client;
excludeTools?: string[];
}
export async function getServer(): Promise<Server> {
const appService = new ApplicationService();
const automationServer = await getAutomationServer(appService);
const [automationServerTransport, automationClientTransport] = createInMemoryTransportPair();
const automationClient = new Client({ name: 'Automation Client', version: '1.0.0' });
await automationServer.connect(automationServerTransport);
await automationClient.connect(automationClientTransport);
const multiplexServer = new MultiplexServer(
[{ subServer: automationClient }],
{
name: 'VS Code Automation + Playwright Server',
version: '1.0.0',
title: 'Contains tools that can interact with a local build of VS Code. Used for verifying UI behavior.'
}
);
const closables: { close(): Promise<void> }[] = [];
const createPlaywrightServer = async (app: Application) => {
const playwrightServer = await getPlaywrightServer(app);
const [playwrightServerTransport, playwrightClientTransport] = createInMemoryTransportPair();
const playwrightClient = new Client({ name: 'Playwright Client', version: '1.0.0' });
await playwrightServer.connect(playwrightServerTransport);
await playwrightClient.connect(playwrightClientTransport);
await playwrightClient.notification({ method: 'notifications/initialized' });
// Add subserver with optional tool exclusions
multiplexServer.addSubServer({
subServer: playwrightClient,
excludeTools: [
// 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',
// 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();
closables.push(
playwrightClient,
playwrightServer,
playwrightServerTransport,
playwrightClientTransport,
{
async close() {
multiplexServer.removeSubServer(playwrightClient);
multiplexServer.sendToolListChanged();
}
}
);
};
const disposePlaywrightServer = async () => {
while (closables.length) {
closables.pop()?.close();
}
};
appService.onApplicationChange(async app => {
if (app) {
await createPlaywrightServer(app);
} else {
await disposePlaywrightServer();
}
});
if (opts.autostart) {
await appService.getOrCreateApplication();
}
return multiplexServer.server;
}
/**
* High-level MCP server that provides a simpler API for working with resources, tools, and prompts.
* For advanced usage (like sending notifications or setting custom request handlers), use the underlying
* Server instance available via the `server` property.
*/
export class MultiplexServer {
/**
* The underlying Server instance, useful for advanced operations like sending notifications.
*/
readonly server: Server;
private readonly _subServerToToolSet = new Map<Client, Set<string>>();
private readonly _subServerToExcludedTools = new Map<Client, Set<string>>();
private readonly _subServers: Client[];
constructor(subServerConfigs: SubServerConfig[], serverInfo: Implementation, options?: ServerOptions) {
this.server = new Server(serverInfo, options);
this._subServers = [];
// Process configurations and set up subservers
for (const config of subServerConfigs) {
this._subServers.push(config.subServer);
if (config.excludeTools && config.excludeTools.length > 0) {
this._subServerToExcludedTools.set(config.subServer, new Set(config.excludeTools));
}
}
this.setToolRequestHandlers();
}
async start(): Promise<void> {
await this.server.sendToolListChanged();
}
/**
* Attaches to the given transport, starts it, and starts listening for messages.
*
* The `server` object assumes ownership of the Transport, replacing any callbacks that have already been set, and expects that it is the only user of the Transport instance going forward.
*/
async connect(transport: Transport): Promise<void> {
return await this.server.connect(transport);
}
/**
* Closes the connection.
*/
async close(): Promise<void> {
await this.server.close();
}
private _toolHandlersInitialized = false;
private setToolRequestHandlers() {
if (this._toolHandlersInitialized) {
return;
}
this.server.assertCanSetRequestHandler(
ListToolsRequestSchema.shape.method.value,
);
this.server.assertCanSetRequestHandler(
CallToolRequestSchema.shape.method.value,
);
this.server.registerCapabilities({
tools: {
listChanged: true
}
});
this.server.setRequestHandler(
ListToolsRequestSchema,
async (): Promise<ListToolsResult> => {
const tools: Tool[] = [];
for (const subServer of this._subServers) {
const result = await subServer.listTools();
const allToolNames = new Set(result.tools.map(t => t.name));
const excludedForThisServer = this._subServerToExcludedTools.get(subServer) || new Set();
const filteredTools = result.tools.filter(tool => !excludedForThisServer.has(tool.name));
this._subServerToToolSet.set(subServer, allToolNames);
tools.push(...filteredTools);
}
return { tools };
},
);
this.server.setRequestHandler(
CallToolRequestSchema,
async (request, extra): Promise<CallToolResult> => {
const toolName = request.params.name;
for (const subServer of this._subServers) {
const toolSet = this._subServerToToolSet.get(subServer);
const excludedForThisServer = this._subServerToExcludedTools.get(subServer) || new Set();
if (toolSet?.has(toolName)) {
// Check if tool is excluded for this specific subserver
if (excludedForThisServer.has(toolName)) {
throw new McpError(ErrorCode.InvalidParams, `Tool with ID ${toolName} is excluded`);
}
return await subServer.request(
{
method: 'tools/call',
params: request.params
},
CallToolResultSchema
);
}
}
throw new McpError(ErrorCode.InvalidParams, `Tool with ID ${toolName} not found`);
},
);
this._toolHandlersInitialized = true;
}
/**
* Checks if the server is connected to a transport.
* @returns True if the server is connected
*/
isConnected() {
return this.server.transport !== undefined;
}
/**
* Sends a tool list changed event to the client, if connected.
*/
sendToolListChanged() {
if (this.isConnected()) {
this.server.sendToolListChanged();
}
}
addSubServer(config: SubServerConfig) {
this._subServers.push(config.subServer);
if (config.excludeTools && config.excludeTools.length > 0) {
this._subServerToExcludedTools.set(config.subServer, new Set(config.excludeTools));
}
this.sendToolListChanged();
}
removeSubServer(subServer: Client) {
const index = this._subServers.indexOf(subServer);
if (index >= 0) {
const removed = this._subServers.splice(index, 1);
if (removed.length > 0) {
// Clean up excluded tools mapping
this._subServerToExcludedTools.delete(subServer);
this.sendToolListChanged();
}
} else {
throw new Error('SubServer not found.');
}
}
}

View File

@@ -1,24 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { createConnection } from '@playwright/mcp';
import { getApplication } from './application';
import { Application } from '../../automation';
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
export async function getServer(app?: Application): Promise<Server> {
const application = app ?? await getApplication();
const connection = await createConnection(
{
capabilities: ['core', 'pdf', 'vision']
},
// eslint-disable-next-line local/code-no-any-casts
() => Promise.resolve(application.code.driver.browserContext as any)
);
application.code.driver.browserContext.on('close', async () => {
await connection.close();
});
return connection;
}

View File

@@ -3,11 +3,19 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { getServer } from './multiplex';
import { getServer } from './automation';
import { ApplicationService } from './application';
import { opts } from './options';
const transport: StdioServerTransport = new StdioServerTransport();
(async () => {
const server = await getServer();
const appService = new ApplicationService();
const server = await getServer(appService);
if (opts.autostart) {
await appService.getOrCreateApplication();
}
await server.connect(transport);
})().catch(err => {
transport.close();