Skip to content

feat(mcp): add vendored Playwright MCP server for browser automation#770

Closed
atxtechbro wants to merge 4 commits intomainfrom
feature/playwright-mcp-server-766
Closed

feat(mcp): add vendored Playwright MCP server for browser automation#770
atxtechbro wants to merge 4 commits intomainfrom
feature/playwright-mcp-server-766

Conversation

@atxtechbro
Copy link
Copy Markdown
Owner

Problem & Solution

Problem: Need browser automation capabilities through MCP protocol, but want stability and control over the implementation
Solution: Vendor Microsoft's Playwright MCP server as a local copy that we maintain independently
Keywords: playwright, MCP, browser automation, vendored, self-contained, microsoft/playwright-mcp

Related Issues

Closes #766

Technical Details

Files changed:

  • mcp/servers/playwright-mcp/ - Vendored Playwright MCP server (commit 59f1d67)
  • mcp/playwright-mcp-wrapper.sh - Runtime wrapper script
  • mcp/setup-playwright-mcp.sh - Setup script for Node.js dependencies
  • mcp/mcp.json - Added playwright server configuration
  • mcp/setup-all-mcp-servers.sh - Added playwright to setup sequence

Key modifications:

  • Removed unnecessary files (tests, examples, dev configs, .github)
  • Added attribution README with vendoring details
  • Integrated with existing MCP infrastructure

Alternative approaches considered:

  • Git submodule - rejected for lack of control and surprise updates
  • npm/pip install - rejected for external dependency management
  • Tracking upstream - rejected for stability requirements

Testing

Testing will be done as a follow-up task after merge due to the complexity of testing in a feature branch. The server should:

  • Start successfully after running mcp/setup-playwright-mcp.sh
  • Be accessible through MCP clients with the playwright server name
  • Provide browser automation tools through the MCP protocol

Checklist

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have added/updated tests that prove my fix is effective or that my feature works (follow-up issue)
  • I have updated the documentation accordingly (added README)

- Vendored Microsoft's Playwright MCP server (commit 59f1d67)
- Added wrapper script and setup script for Node.js environment
- Integrated with MCP configuration and setup-all script
- Cleaned unnecessary files (tests, examples, dev configs)
- Added attribution README with vendoring details

This is a deliberate choice to vendor rather than use as dependency
for stability, customization, and self-containment.

Closes #766
@amazon-q-developer
Copy link
Copy Markdown
Contributor

⏳ I'm reviewing this pull request for security vulnerabilities and code quality issues. I'll provide an update when I'm done

}

protected override async _doObtainBrowser(): Promise<playwright.Browser> {
const response = await fetch(new URL(`/json/launch`, this.browserConfig.browserAgent), {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Description: The fetch operation in _doObtainBrowser method lacks error handling for network failures or invalid responses. Implement a try-catch block around the fetch operation to handle potential network errors and validate the response status.

Severity: High

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix addresses the lack of error handling in the fetch operation within the _doObtainBrowser method. A try-catch block has been implemented around the fetch operation to handle potential network errors. Additionally, the response status is now validated using response.ok before proceeding with parsing the JSON. This ensures that any network failures or invalid responses are properly caught and handled, improving the robustness of the code.

Suggested change
const response = await fetch(new URL(`/json/launch`, this.browserConfig.browserAgent), {
}
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
try {
const response = await fetch(new URL(`/json/launch`, this.browserConfig.browserAgent), {
method: 'POST',
body: JSON.stringify({
browserType: this.browserConfig.browserName,
userDataDir: this.browserConfig.userDataDir ?? await this._createUserDataDir(),
launchOptions: this.browserConfig.launchOptions,
contextOptions: this.browserConfig.contextOptions,
} as LaunchBrowserRequest),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const info = await response.json() as BrowserInfo;
if (info.error)
throw new Error(info.error);
return await playwright.chromium.connectOverCDP(`http://localhost:${info.cdpPort}/`);
} catch (error) {
console.error('Error in _doObtainBrowser:', error);
throw error;
}
}
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {

}

private _onError(event: WebSocket.ErrorEvent) {
debugLogger(`<ws error> message=${event.message} type=${event.type} target=${event.target}`);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Description: Log Injection occurs when untrusted user input is directly written to log files without proper sanitization. This can allow attackers to manipulate log entries, potentially leading to security issues like log forging or cross-site scripting. To prevent this, always sanitize user input before logging by removing or encoding newline characters, using string encoding functions, and leveraging built-in sanitization features of logging libraries when available. Learn more - https://cwe.mitre.org/data/definitions/117.html

Severity: High

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix involves using a sanitizeLog function to sanitize the user input (event properties) before logging. This prevents potential log injection attacks by removing or encoding any malicious characters or newlines in the logged data.

Suggested change
debugLogger(`<ws error> message=${event.message} type=${event.type} target=${event.target}`);
private _onError(event: WebSocket.ErrorEvent) {
// Import the sanitizeLog function from a utility module
// @ts-ignore
import { sanitizeLog } from './logUtils';
debugLogger(`<ws error> message=${sanitizeLog(event.message)} type=${sanitizeLog(event.type)} target=${sanitizeLog(event.target)}`);
this._dispose();
}

private _terminatePromises = new Map<ManualPromise<Error>, string[]>();
private _isClosed = false;

reject(error: Error) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Description: The reject method resolves promises instead of rejecting them, which may lead to unexpected behavior. Consider renaming the reject method to terminate or similar, as it's not actually rejecting promises.

Severity: High

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix addresses the issue by renaming the reject method to terminate. This change clarifies the method's purpose and prevents confusion, as it was not actually rejecting promises but resolving them. The method's functionality remains the same, but the new name better reflects its behavior of terminating the scope and resolving all associated promises with the given error.

Suggested change
reject(error: Error) {
private _terminatePromises = new Map<ManualPromise<Error>, string[]>();
private _isClosed = false;
terminate(error: Error) {
this._isClosed = true;
this._terminateError = error;
for (const p of this._terminatePromises.keys())


let result: ToolActionResult | undefined;
try {
await Promise.race([
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Description: The _raceAgainstModalDialogs method doesn't handle the case where the action resolves after a dialog is shown. Consider adding logic to handle both dialog shown and action completion scenarios, possibly using a flag to track which event occurred first.

Severity: High

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix addresses the issue by introducing a flag dialogShown to track whether a dialog was shown before the action completed. The _raceAgainstModalDialogs method now handles both scenarios: when the action completes first and when a dialog is shown first. If a dialog is shown, the action result is discarded, and a default empty result is returned if no action result is available.

Suggested change
await Promise.race([
};
let result: ToolActionResult | undefined;
let dialogShown = false;
try {
await Promise.race([
action().then(r => {
if (!dialogShown) {
result = r;
}
}),
this._pendingAction.dialogShown.then(() => {
dialogShown = true;
}),
]);
} finally {
this._pendingAction = undefined;
}
return result || { content: [] }; // Return a default result if no action result
}
private _javaScriptBlocked(): boolean {

.option('--viewport-size <size>', 'specify browser viewport size in pixels, for example "1280, 720"')
.option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)')
.addOption(new Option('--extension', 'Allow connecting to a running browser instance (Edge/Chrome only). Requires the \'Playwright MCP\' browser extension to be installed.').hideHelp())
.action(async options => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Description: No error handling for potential failures in async operations within the action callback. Wrap the async operations in a try-catch block to handle potential errors gracefully.

Severity: High

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix addresses the lack of error handling in the async operations within the action callback. A try-catch block has been added to wrap the entire async function body, catching any potential errors that may occur during execution. If an error is caught, it is logged to the console, and the process exits with a status code of 1, ensuring graceful error handling and preventing unhandled promise rejections.

Suggested change
.action(async options => {
.option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)')
.addOption(new Option('--extension', 'Allow connecting to a running browser instance (Edge/Chrome only). Requires the \'Playwright MCP\' browser extension to be installed.').hideHelp())
.action(async options => {
try {
const config = await resolveCLIConfig(options);
const httpServer = config.server.port !== undefined ? await startHttpServer(config.server) : undefined;
if (config.extension) {
if (!httpServer)
throw new Error('--port parameter is required for extension mode');
// Point CDP endpoint to the relay server.
config.browser.cdpEndpoint = await startCDPRelayServer(httpServer);
}
const server = new Server(config);
server.setupExitWatchdog();
if (httpServer)
await startHttpTransport(httpServer, server);
else
await startStdioTransport(server);
if (config.saveTrace) {
const server = await startTraceViewerServer();
const urlPrefix = server.urlPrefix('human-readable');
const url = urlPrefix + '/trace/index.html?trace=' + config.browser.launchOptions.tracesDir + '/trace.json';
// eslint-disable-next-line no-console
console.error('
Trace viewer listening on ' + url);
}
} catch (error) {
console.error('An error occurred:', error);
process.exit(1);
}
});

private _handleParsedMessage(object: any) {
if (object.id && this._callbacks.has(object.id)) {
const callback = this._callbacks.get(object.id)!;
this._callbacks.delete(object.id);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Description: Potential NoSQL injection risk detected. Unsafe input is being used
directly in database queries without proper validation or sanitization.
This can allow attackers to manipulate queries, leading to unauthorized
data access, modification, or deletion. To mitigate this risk, always
validate and sanitize unsafe input, use parameterized queries or prepared
statements. Learn more - https://cwe.mitre.org/data/definitions/943.html

Severity: High

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix introduces a sanitizeInput method to validate and sanitize the object.id before using it in the delete operation, mitigating the risk of NoSQL injection.

Suggested change
this._callbacks.delete(object.id);
private _handleParsedMessage(object: any) {
if (object.id && this._callbacks.has(object.id)) {
const callback = this._callbacks.get(object.id)!;
// Use a validated and sanitized version of object.id
const sanitizedId = this.sanitizeInput(object.id);
this._callbacks.delete(sanitizedId);
if (object.error)
callback.reject(new Error(object.error.message));
else
callback.resolve(object.result);
} else if (object.id) {
debugLogger('← Extension: unexpected response', object);
} else {
this.onmessage?.(object.method, object.params);
}
}
// Add a method to sanitize input
private sanitizeInput(input: any): string {
// Implement proper input validation and sanitization
// This is a placeholder and should be replaced with appropriate logic
return String(input).replace(/[^a-zA-Z0-9]/g, '');
}

async newTab(): Promise<Tab> {
const { browserContext } = await this._ensureBrowserContext();
const page = await browserContext.newPage();
this._currentTab = this._tabs.find(t => t.page === page)!;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Description: Potential NoSQL injection risk detected. Unsafe input is being used
directly in database queries without proper validation or sanitization.
This can allow attackers to manipulate queries, leading to unauthorized
data access, modification, or deletion. To mitigate this risk, always
validate and sanitize unsafe input, use parameterized queries or prepared
statements. Learn more - https://cwe.mitre.org/data/definitions/943.html

Severity: High

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix removes the non-null assertion operator (!) and adds a type guard to ensure that the found tab is of type Tab. It also includes error handling if no matching tab is found, preventing potential null reference errors.

Suggested change
this._currentTab = this._tabs.find(t => t.page === page)!;
async newTab(): Promise<Tab> {
const { browserContext } = await this._ensureBrowserContext();
const page = await browserContext.newPage();
// Use Array.prototype.find() with type guard to ensure non-null result
const foundTab = this._tabs.find((t): t is Tab => t.page === page);
if (foundTab) {
this._currentTab = foundTab;
} else {
throw new Error('Tab not found');
}
return this._currentTab;
}

return escapeWithQuotes(text, '\'');
}

export function formatObject(value: any, indent = ' '): string {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Description: The formatObject function doesn't handle circular references, which could lead to stack overflow errors. Implement a mechanism to detect and handle circular references in the object being formatted.

Severity: High

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix addresses the issue of circular references in the formatObject function by introducing a WeakSet to keep track of objects that have already been encountered during the formatting process. If a circular reference is detected, it is replaced with the string '[Circular]'. This prevents stack overflow errors and allows the function to handle complex object structures with circular dependencies.

Suggested change
export function formatObject(value: any, indent = ' '): string {
return escapeWithQuotes(text, '\'');
}
export function formatObject(value: any, indent = ' ', seen = new WeakSet()): string {
if (typeof value === 'string')
return quote(value);
if (Array.isArray(value))
return `[${value.map(o => formatObject(o, indent, seen)).join(', ')}]`;
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) {
return '[Circular]';
}
seen.add(value);
const keys = Object.keys(value).filter(key => value[key] !== undefined).sort();
if (!keys.length)
return '{}';
const tokens: string[] = [];
for (const key of keys)
tokens.push(`${key}: ${formatObject(value[key], indent + ' ', seen)}`);
seen.delete(value);
return `{
${indent}${tokens.join(`,
${indent}`)}
}`;
}
return String(value);

});
transport.onclose = () => {
if (transport.sessionId)
sessions.delete(transport.sessionId);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Description: Potential NoSQL injection risk detected. Unsafe input is being used
directly in database queries without proper validation or sanitization.
This can allow attackers to manipulate queries, leading to unauthorized
data access, modification, or deletion. To mitigate this risk, always
validate and sanitize unsafe input, use parameterized queries or prepared
statements. Learn more - https://cwe.mitre.org/data/definitions/943.html

Severity: High

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix adds a check to ensure that the sessionId exists in the sessions Map before attempting to delete it, preventing potential NoSQL injection by validating the input before performing the delete operation.

Suggested change
sessions.delete(transport.sessionId);
}
});
transport.onclose = () => {
if (transport.sessionId && sessions.has(transport.sessionId)) {
sessions.delete(transport.sessionId);
}
};
await server.createConnection(transport);
await transport.handleRequest(req, res);

this._contextGetter = contextGetter;
}

async createContext(): Promise<{ browserContext: BrowserContext, close: () => Promise<void> }> {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Description: The createContext method doesn't handle potential errors from _contextGetter or browserContext.close(). Wrap the _contextGetter call in a try-catch block and handle potential errors. Also, consider handling errors in the close function.

Severity: High

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix addresses the comment by wrapping the _contextGetter call in a try-catch block to handle potential errors during context creation. It also modifies the close function to handle errors that may occur during context closure. This improvement enhances error handling and provides better visibility into potential issues during context creation and closure.

Suggested change
async createContext(): Promise<{ browserContext: BrowserContext, close: () => Promise<void> }> {
}
async createContext(): Promise<{ browserContext: BrowserContext, close: () => Promise<void> }> {
try {
const browserContext = await this._contextGetter();
return {
browserContext,
close: async () => {
try {
await browserContext.close();
} catch (error) {
console.error('Error closing browser context:', error);
}
}
};
} catch (error) {
console.error('Error creating browser context:', error);
throw error;
}
}
}

@amazon-q-developer
Copy link
Copy Markdown
Contributor

✅ I finished the code review, and left comments with the issues I found. I will now generate code fix suggestions.

@atxtechbro atxtechbro force-pushed the feature/playwright-mcp-server-766 branch from 4f7f459 to 6250772 Compare July 21, 2025 14:07
@atxtechbro
Copy link
Copy Markdown
Owner Author

Closing this PR in favor of a much simpler approach that was implemented in PR #953.

Instead of vendoring and maintaining our own copy of the Playwright MCP server (which adds 9,350 lines of code and significant maintenance burden), we're now using the official @playwright/mcp package directly via npx:

"playwright": {
  "command": "npx",
  "args": [
    "@playwright/mcp@latest"
  ],
  "env": {
    "FASTMCP_LOG_LEVEL": "ERROR"
  }
}

This approach has several advantages that align with our principles:

  1. Subtraction Creates Value: Eliminates ~9,350 lines of code we don't need to maintain
  2. Spilled Coffee Principle: Works immediately without manual setup steps
  3. Systems Stewardship: Follows the pattern of using official packages where available
  4. Versioning Mindset: Always uses the latest version through the @latest tag

This is similar to our recent migration from custom GitLab MCP server to @zereight/mcp-gitlab in PR #949, where we found that using official packages reduces maintenance burden and improves reliability.

The direct npx approach has been tested and confirmed working with both Amazon Q and Claude Code.

@atxtechbro atxtechbro closed this Jul 22, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: add vendored Playwright MCP server for browser automation

1 participant