chore: prep folders for copying upstream (#987)

This commit is contained in:
Pavel Feldman
2025-09-03 16:02:07 -07:00
committed by GitHub
parent 2461f32d05
commit d142f13d80
73 changed files with 128 additions and 953 deletions

View File

@@ -16,7 +16,7 @@
import { defineConfig } from '@playwright/test';
import type { TestOptions } from '../tests/fixtures.js';
import type { TestOptions } from '../tests/fixtures';
export default defineConfig<TestOptions>({
testDir: './tests',

View File

@@ -16,8 +16,8 @@
import React, { useState, useEffect, useCallback } from 'react';
import { createRoot } from 'react-dom/client';
import { Button, TabItem } from './tabItem.js';
import type { TabInfo } from './tabItem.js';
import { Button, TabItem } from './tabItem';
import type { TabInfo } from './tabItem';
type Status =
| { type: 'connecting'; message: string }

View File

@@ -16,9 +16,9 @@
import React, { useState, useEffect } from 'react';
import { createRoot } from 'react-dom/client';
import { Button, TabItem } from './tabItem.js';
import { Button, TabItem } from './tabItem';
import type { TabInfo } from './tabItem.js';
import type { TabInfo } from './tabItem';
interface ConnectionStatus {
isConnected: boolean;

View File

@@ -17,11 +17,11 @@
import fs from 'fs';
import path from 'path';
import { chromium } from 'playwright';
import { test as base, expect } from '../../tests/fixtures.js';
import { test as base, expect } from '../../tests/fixtures';
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
import type { BrowserContext } from 'playwright';
import type { StartClient } from '../../tests/fixtures.js';
import type { StartClient } from '../../tests/fixtures';
type BrowserWithExtension = {
userDataDir: string;

View File

@@ -1,7 +1,5 @@
[*]
./tools/
./mcp/
./utils/
[program.ts]
***
[index.ts]
***

5
src/browser/DEPS.list Normal file
View File

@@ -0,0 +1,5 @@
[*]
./tools/
../sdk/
../log.ts
../package.ts

View File

@@ -14,6 +14,7 @@
* limitations under the License.
*/
import crypto from 'crypto';
import fs from 'fs';
import net from 'net';
import path from 'path';
@@ -23,8 +24,7 @@ import * as playwright from 'playwright';
import { registryDirectory } from 'playwright-core/lib/server/registry/index';
// @ts-ignore
import { startTraceViewerServer } from 'playwright-core/lib/server';
import { logUnhandledError, testDebug } from './utils/log';
import { createHash } from './utils/guid';
import { logUnhandledError, testDebug } from '../log';
import { outputFile } from './config';
import type { FullConfig } from './config';
@@ -247,3 +247,7 @@ async function startTraceServer(config: FullConfig, rootPath: string | undefined
console.error('\nTrace viewer listening on ' + url);
return tracesDir;
}
function createHash(data: string): string {
return crypto.createHash('sha256').update(data).digest('hex').slice(0, 7);
}

View File

@@ -17,16 +17,16 @@
import { fileURLToPath } from 'url';
import { FullConfig } from './config';
import { Context } from './context';
import { logUnhandledError } from './utils/log';
import { logUnhandledError } from '../log';
import { Response } from './response';
import { SessionLog } from './sessionLog';
import { filteredTools } from './tools';
import { toMcpTool } from './mcp/tool';
import { toMcpTool } from '../sdk/tool';
import type { Tool } from './tools/tool';
import type { BrowserContextFactory } from './browserContextFactory';
import type * as mcpServer from './mcp/server';
import type { ServerBackend } from './mcp/server';
import type * as mcpServer from '../sdk/server';
import type { ServerBackend } from '../sdk/server';
export class BrowserServerBackend implements ServerBackend {
private _tools: Tool[];

View File

@@ -18,9 +18,8 @@ import fs from 'fs';
import os from 'os';
import path from 'path';
import { devices } from 'playwright';
import { sanitizeForFilePath } from './utils/fileUtils';
import type { Config, ToolCapability } from '../config.js';
import type { Config, ToolCapability } from '../../config';
import type { BrowserContextOptions, LaunchOptions } from 'playwright';
export type CLIOptions = {
@@ -318,3 +317,11 @@ function envToBoolean(value: string | undefined): boolean | undefined {
function envToString(value: string | undefined): string | undefined {
return value ? value.trim() : undefined;
}
function sanitizeForFilePath(s: string) {
const sanitize = (s: string) => s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
const separator = s.lastIndexOf('.');
if (separator === -1)
return sanitize(s);
return sanitize(s.substring(0, separator)) + '.' + sanitize(s.substring(separator + 1));
}

View File

@@ -17,7 +17,7 @@
import debug from 'debug';
import * as playwright from 'playwright';
import { logUnhandledError } from './utils/log';
import { logUnhandledError } from '../log';
import { Tab } from './tab';
import { outputFile } from './config';

View File

@@ -18,7 +18,7 @@ import fs from 'fs';
import path from 'path';
import { Response } from './response';
import { logUnhandledError } from './utils/log';
import { logUnhandledError } from '../log';
import { outputFile } from './config';
import type { FullConfig } from './config';

View File

@@ -17,8 +17,8 @@
import { EventEmitter } from 'events';
import * as playwright from 'playwright';
import { callOnPageNoTrace, waitForCompletion } from './tools/utils';
import { logUnhandledError } from './utils/log';
import { ManualPromise } from './mcp/manualPromise';
import { logUnhandledError } from '../log';
import { ManualPromise } from '../sdk/manualPromise';
import { ModalState } from './tools/tool';
import type { Context } from './context';

View File

@@ -0,0 +1,3 @@
[*]
../
../../sdk/

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { z } from '../mcp/bundle.js';
import { z } from '../../sdk/bundle';
import { defineTabTool, defineTool } from './tool';
const close = defineTool({

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { z } from '../mcp/bundle.js';
import { z } from '../../sdk/bundle';
import { defineTabTool } from './tool';
const console = defineTabTool({

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { z } from '../mcp/bundle.js';
import { z } from '../../sdk/bundle';
import { defineTabTool } from './tool';
const handleDialog = defineTabTool({

View File

@@ -14,9 +14,9 @@
* limitations under the License.
*/
import { z } from '../mcp/bundle.js';
import { z } from '../../sdk/bundle';
import { defineTabTool } from './tool';
import * as javascript from '../utils/codegen.js';
import * as javascript from '../codegen';
import { generateLocator } from './utils';
import type * as playwright from 'playwright';

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { z } from '../mcp/bundle.js';
import { z } from '../../sdk/bundle';
import { defineTabTool } from './tool';
const uploadFile = defineTabTool({

View File

@@ -14,10 +14,10 @@
* limitations under the License.
*/
import { z } from '../mcp/bundle.js';
import { z } from '../../sdk/bundle';
import { defineTabTool } from './tool';
import { generateLocator } from './utils';
import * as javascript from '../utils/codegen.js';
import * as javascript from '../codegen';
const fillForm = defineTabTool({
capability: 'core',

View File

@@ -17,7 +17,7 @@
import { fork } from 'child_process';
import path from 'path';
import { z } from '../mcp/bundle.js';
import { z } from '../../sdk/bundle';
import { defineTool } from './tool';
const install = defineTool({

View File

@@ -14,11 +14,11 @@
* limitations under the License.
*/
import { z } from '../mcp/bundle.js';
import { z } from '../../sdk/bundle';
import { defineTabTool } from './tool';
import { elementSchema } from './snapshot';
import { generateLocator } from './utils';
import * as javascript from '../utils/codegen.js';
import * as javascript from '../codegen';
const pressKey = defineTabTool({
capability: 'core',

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { z } from '../mcp/bundle.js';
import { z } from '../../sdk/bundle';
import { defineTabTool } from './tool';
const elementSchema = z.object({

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { z } from '../mcp/bundle.js';
import { z } from '../../sdk/bundle';
import { defineTool, defineTabTool } from './tool';
const navigate = defineTool({

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { z } from '../mcp/bundle.js';
import { z } from '../../sdk/bundle';
import { defineTabTool } from './tool';
import type * as playwright from 'playwright';

View File

@@ -14,9 +14,9 @@
* limitations under the License.
*/
import { z } from '../mcp/bundle.js';
import { z } from '../../sdk/bundle';
import { defineTabTool } from './tool';
import * as javascript from '../utils/codegen.js';
import * as javascript from '../codegen';
const pdfSchema = z.object({
filename: z.string().optional().describe('File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.'),

View File

@@ -14,9 +14,9 @@
* limitations under the License.
*/
import { z } from '../mcp/bundle.js';
import { z } from '../../sdk/bundle';
import { defineTabTool } from './tool';
import * as javascript from '../utils/codegen.js';
import * as javascript from '../codegen';
import { generateLocator } from './utils';
import type * as playwright from 'playwright';

View File

@@ -14,9 +14,9 @@
* limitations under the License.
*/
import { z } from '../mcp/bundle.js';
import { z } from '../../sdk/bundle';
import { defineTabTool, defineTool } from './tool';
import * as javascript from '../utils/codegen.js';
import * as javascript from '../codegen';
import { generateLocator } from './utils';
const snapshot = defineTool({

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { z } from '../mcp/bundle.js';
import { z } from '../../sdk/bundle';
import { defineTool } from './tool';
const browserTabs = defineTool({

View File

@@ -15,12 +15,12 @@
*/
import type { z } from 'zod';
import type { Context } from '../context.js';
import type { Context } from '../context';
import type * as playwright from 'playwright';
import type { ToolCapability } from '../../config.js';
import type { Tab } from '../tab.js';
import type { Response } from '../response.js';
import type { ToolSchema } from '../mcp/tool.js';
import type { ToolCapability } from '../../../config';
import type { Tab } from '../tab';
import type { Response } from '../response';
import type { ToolSchema } from '../../sdk/tool';
export type FileUploadModalState = {
type: 'fileChooser';

View File

@@ -18,7 +18,7 @@
import { asLocator } from 'playwright-core/lib/utils';
import type * as playwright from 'playwright';
import type { Tab } from '../tab.js';
import type { Tab } from '../tab';
export async function waitForCompletion<R>(tab: Tab, callback: () => Promise<R>): Promise<R> {
const requests = new Set<playwright.Request>();

View File

@@ -14,9 +14,9 @@
* limitations under the License.
*/
import { z } from '../mcp/bundle.js';
import { z } from '../../sdk/bundle';
import { defineTabTool } from './tool';
import * as javascript from '../utils/codegen.js';
import * as javascript from '../codegen';
import { generateLocator } from './utils';
const verifyElement = defineTabTool({

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { z } from '../mcp/bundle.js';
import { z } from '../../sdk/bundle';
import { defineTool } from './tool';
const wait = defineTool({

View File

@@ -1,3 +1,4 @@
[*]
../mcp/
../utils/
../sdk/
../browser/
../log.ts

View File

@@ -30,13 +30,13 @@ import { WebSocket, WebSocketServer } from 'ws';
// @ts-ignore
import { registry } from 'playwright-core/lib/server/registry/index';
import { httpAddressToString } from '../mcp/http.js';
import { logUnhandledError } from '../utils/log.js';
import { ManualPromise } from '../mcp/manualPromise.js';
import { httpAddressToString } from '../sdk/http';
import { logUnhandledError } from '../log';
import { ManualPromise } from '../sdk/manualPromise';
import * as protocol from './protocol';
import type websocket from 'ws';
import type { ClientInfo } from '../browserContextFactory.js';
import type { ClientInfo } from '../browser/browserContextFactory';
import type { ExtensionCommand, ExtensionEvents } from './protocol';

View File

@@ -16,10 +16,10 @@
import debug from 'debug';
import * as playwright from 'playwright';
import { startHttpServer } from '../mcp/http.js';
import { startHttpServer } from '../sdk/http';
import { CDPRelayServer } from './cdpRelay';
import type { BrowserContextFactory, ClientInfo } from '../browserContextFactory.js';
import type { BrowserContextFactory, ClientInfo } from '../browser/browserContextFactory';
const debugLogger = debug('pw:mcp:relay');

View File

@@ -14,15 +14,15 @@
* limitations under the License.
*/
import { BrowserServerBackend } from './browserServerBackend';
import { resolveConfig } from './config';
import { contextFactory } from './browserContextFactory';
import * as mcpServer from './mcp/server';
import { packageJSON } from './utils/package';
import { BrowserServerBackend } from './browser/browserServerBackend';
import { resolveConfig } from './browser/config';
import { contextFactory } from './browser/browserContextFactory';
import * as mcpServer from './sdk/server';
import { packageJSON } from './package';
import type { Config } from '../config.js';
import type { Config } from '../config';
import type { BrowserContext } from 'playwright';
import type { BrowserContextFactory } from './browserContextFactory';
import type { BrowserContextFactory } from './browser/browserContextFactory';
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
export async function createConnection(userConfig: Config = {}, contextGetter?: () => Promise<BrowserContext>): Promise<Server> {

View File

@@ -1,108 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import debug from 'debug';
import type { Tool, ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
export type LLMToolCall = {
name: string;
arguments: any;
id: string;
};
export type LLMTool = {
name: string;
description: string;
inputSchema: any;
};
export type LLMMessage =
| { role: 'user'; content: string }
| { role: 'assistant'; content: string; toolCalls?: LLMToolCall[] }
| { role: 'tool'; toolCallId: string; content: string; isError?: boolean };
export type LLMConversation = {
messages: LLMMessage[];
tools: LLMTool[];
};
export interface LLMDelegate {
createConversation(task: string, tools: Tool[], oneShot: boolean): LLMConversation;
makeApiCall(conversation: LLMConversation): Promise<LLMToolCall[]>;
addToolResults(conversation: LLMConversation, results: Array<{ toolCallId: string; content: string; isError?: boolean }>): void;
checkDoneToolCall(toolCall: LLMToolCall): string | null;
}
export async function runTask(delegate: LLMDelegate, client: Client, task: string, oneShot: boolean = false): Promise<LLMMessage[]> {
const { tools } = await client.listTools();
const taskContent = oneShot ? `Perform following task: ${task}.` : `Perform following task: ${task}. Once the task is complete, call the "done" tool.`;
const conversation = delegate.createConversation(taskContent, tools, oneShot);
for (let iteration = 0; iteration < 5; ++iteration) {
debug('history')('Making API call for iteration', iteration);
const toolCalls = await delegate.makeApiCall(conversation);
if (toolCalls.length === 0)
throw new Error('Call the "done" tool when the task is complete.');
const toolResults: Array<{ toolCallId: string; content: string; isError?: boolean }> = [];
for (const toolCall of toolCalls) {
const doneResult = delegate.checkDoneToolCall(toolCall);
if (doneResult !== null)
return conversation.messages;
const { name, arguments: args, id } = toolCall;
try {
debug('tool')(name, args);
const response = await client.callTool({
name,
arguments: args,
});
const responseContent = (response.content || []) as (TextContent | ImageContent)[];
debug('tool')(responseContent);
const text = responseContent.filter(part => part.type === 'text').map(part => part.text).join('\n');
toolResults.push({
toolCallId: id,
content: text,
});
} catch (error) {
debug('tool')(error);
toolResults.push({
toolCallId: id,
content: `Error while executing tool "${name}": ${error instanceof Error ? error.message : String(error)}\n\nPlease try to recover and complete the task.`,
isError: true,
});
// Skip remaining tool calls for this iteration
for (const remainingToolCall of toolCalls.slice(toolCalls.indexOf(toolCall) + 1)) {
toolResults.push({
toolCallId: remainingToolCall.id,
content: `This tool call is skipped due to previous error.`,
isError: true,
});
}
break;
}
}
delegate.addToolResults(conversation, toolResults);
if (oneShot)
return conversation.messages;
}
throw new Error('Failed to perform step, max attempts reached');
}

View File

@@ -1,177 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type Anthropic from '@anthropic-ai/sdk';
import type { LLMDelegate, LLMConversation, LLMToolCall, LLMTool } from './loop';
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
const model = 'claude-sonnet-4-20250514';
export class ClaudeDelegate implements LLMDelegate {
private _anthropic: Anthropic | undefined;
async anthropic(): Promise<Anthropic> {
if (!this._anthropic) {
const anthropic = await import('@anthropic-ai/sdk');
this._anthropic = new anthropic.Anthropic() as unknown as Anthropic;
}
return this._anthropic;
}
createConversation(task: string, tools: Tool[], oneShot: boolean): LLMConversation {
const llmTools: LLMTool[] = tools.map(tool => ({
name: tool.name,
description: tool.description || '',
inputSchema: tool.inputSchema,
}));
if (!oneShot) {
llmTools.push({
name: 'done',
description: 'Call this tool when the task is complete.',
inputSchema: {
type: 'object',
properties: {},
},
});
}
return {
messages: [{
role: 'user',
content: task
}],
tools: llmTools,
};
}
async makeApiCall(conversation: LLMConversation): Promise<LLMToolCall[]> {
// Convert generic messages to Claude format
const claudeMessages: Anthropic.Messages.MessageParam[] = [];
for (const message of conversation.messages) {
if (message.role === 'user') {
claudeMessages.push({
role: 'user',
content: message.content
});
} else if (message.role === 'assistant') {
const content: Anthropic.Messages.ContentBlock[] = [];
// Add text content
if (message.content) {
content.push({
type: 'text',
text: message.content,
citations: []
});
}
// Add tool calls
if (message.toolCalls) {
for (const toolCall of message.toolCalls) {
content.push({
type: 'tool_use',
id: toolCall.id,
name: toolCall.name,
input: toolCall.arguments
});
}
}
claudeMessages.push({
role: 'assistant',
content
});
} else if (message.role === 'tool') {
// Tool results are added differently - we need to find if there's already a user message with tool results
const lastMessage = claudeMessages[claudeMessages.length - 1];
const toolResult: Anthropic.Messages.ToolResultBlockParam = {
type: 'tool_result',
tool_use_id: message.toolCallId,
content: message.content,
is_error: message.isError,
};
if (lastMessage && lastMessage.role === 'user' && Array.isArray(lastMessage.content)) {
// Add to existing tool results message
(lastMessage.content as Anthropic.Messages.ToolResultBlockParam[]).push(toolResult);
} else {
// Create new tool results message
claudeMessages.push({
role: 'user',
content: [toolResult]
});
}
}
}
// Convert generic tools to Claude format
const claudeTools: Anthropic.Messages.Tool[] = conversation.tools.map(tool => ({
name: tool.name,
description: tool.description,
input_schema: tool.inputSchema,
}));
const anthropic = await this.anthropic();
const response = await anthropic.messages.create({
model,
max_tokens: 10000,
messages: claudeMessages,
tools: claudeTools,
});
// Extract tool calls and add assistant message to generic conversation
const toolCalls = response.content.filter(block => block.type === 'tool_use') as Anthropic.Messages.ToolUseBlock[];
const textContent = response.content.filter(block => block.type === 'text').map(block => (block as Anthropic.Messages.TextBlock).text).join('');
const llmToolCalls: LLMToolCall[] = toolCalls.map(toolCall => ({
name: toolCall.name,
arguments: toolCall.input as any,
id: toolCall.id,
}));
// Add assistant message to generic conversation
conversation.messages.push({
role: 'assistant',
content: textContent,
toolCalls: llmToolCalls.length > 0 ? llmToolCalls : undefined
});
return llmToolCalls;
}
addToolResults(
conversation: LLMConversation,
results: Array<{ toolCallId: string; content: string; isError?: boolean }>
): void {
for (const result of results) {
conversation.messages.push({
role: 'tool',
toolCallId: result.toolCallId,
content: result.content,
isError: result.isError,
});
}
}
checkDoneToolCall(toolCall: LLMToolCall): string | null {
if (toolCall.name === 'done')
return (toolCall.arguments as { result: string }).result;
return null;
}
}

View File

@@ -1,168 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type OpenAI from 'openai';
import type { LLMDelegate, LLMConversation, LLMToolCall, LLMTool } from './loop';
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
const model = 'gpt-4.1';
export class OpenAIDelegate implements LLMDelegate {
private _openai: OpenAI | undefined;
async openai(): Promise<OpenAI> {
if (!this._openai) {
const oai = await import('openai');
this._openai = new oai.OpenAI() as unknown as OpenAI;
}
return this._openai;
}
createConversation(task: string, tools: Tool[], oneShot: boolean): LLMConversation {
const genericTools: LLMTool[] = tools.map(tool => ({
name: tool.name,
description: tool.description || '',
inputSchema: tool.inputSchema,
}));
if (!oneShot) {
genericTools.push({
name: 'done',
description: 'Call this tool when the task is complete.',
inputSchema: {
type: 'object',
properties: {},
},
});
}
return {
messages: [{
role: 'user',
content: task
}],
tools: genericTools,
};
}
async makeApiCall(conversation: LLMConversation): Promise<LLMToolCall[]> {
// Convert generic messages to OpenAI format
const openaiMessages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [];
for (const message of conversation.messages) {
if (message.role === 'user') {
openaiMessages.push({
role: 'user',
content: message.content
});
} else if (message.role === 'assistant') {
const toolCalls: OpenAI.Chat.Completions.ChatCompletionMessageToolCall[] = [];
if (message.toolCalls) {
for (const toolCall of message.toolCalls) {
toolCalls.push({
id: toolCall.id,
type: 'function',
function: {
name: toolCall.name,
arguments: JSON.stringify(toolCall.arguments)
}
});
}
}
const assistantMessage: OpenAI.Chat.Completions.ChatCompletionAssistantMessageParam = {
role: 'assistant'
};
if (message.content)
assistantMessage.content = message.content;
if (toolCalls.length > 0)
assistantMessage.tool_calls = toolCalls;
openaiMessages.push(assistantMessage);
} else if (message.role === 'tool') {
openaiMessages.push({
role: 'tool',
tool_call_id: message.toolCallId,
content: message.content,
});
}
}
// Convert generic tools to OpenAI format
const openaiTools: OpenAI.Chat.Completions.ChatCompletionTool[] = conversation.tools.map(tool => ({
type: 'function',
function: {
name: tool.name,
description: tool.description,
parameters: tool.inputSchema,
},
}));
const openai = await this.openai();
const response = await openai.chat.completions.create({
model,
messages: openaiMessages,
tools: openaiTools,
tool_choice: 'auto'
});
const message = response.choices[0].message;
// Extract tool calls and add assistant message to generic conversation
const toolCalls = message.tool_calls || [];
const genericToolCalls: LLMToolCall[] = toolCalls.map(toolCall => {
const functionCall = toolCall.function;
return {
name: functionCall.name,
arguments: JSON.parse(functionCall.arguments),
id: toolCall.id,
};
});
// Add assistant message to generic conversation
conversation.messages.push({
role: 'assistant',
content: message.content || '',
toolCalls: genericToolCalls.length > 0 ? genericToolCalls : undefined
});
return genericToolCalls;
}
addToolResults(
conversation: LLMConversation,
results: Array<{ toolCallId: string; content: string; isError?: boolean }>
): void {
for (const result of results) {
conversation.messages.push({
role: 'tool',
toolCallId: result.toolCallId,
content: result.content,
isError: result.isError,
});
}
}
checkDoneToolCall(toolCall: LLMToolCall): string | null {
if (toolCall.name === 'done')
return toolCall.arguments.result;
return null;
}
}

View File

@@ -1,68 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable no-console */
import path from 'path';
import dotenv from 'dotenv';
import { program } from 'commander';
import * as mcpBundle from '../mcp/bundle.js';
import { OpenAIDelegate } from './loopOpenAI';
import { ClaudeDelegate } from './loopClaude';
import { runTask } from './loop';
import type { LLMDelegate } from './loop';
dotenv.config();
async function run(delegate: LLMDelegate) {
const transport = new mcpBundle.StdioClientTransport({
command: 'node',
args: [
path.resolve(__dirname, '../../cli.js'),
'--save-session',
'--output-dir', path.resolve(__dirname, '../../sessions')
],
stderr: 'inherit',
env: process.env as Record<string, string>,
});
const client = new mcpBundle.Client({ name: 'test', version: '1.0.0' });
await client.connect(transport);
await client.ping();
for (const task of tasks) {
const messages = await runTask(delegate, client, task);
for (const message of messages)
console.log(`${message.role}: ${message.content}`);
}
await client.close();
}
const tasks = [
'Open https://playwright.dev/',
];
program
.option('--model <model>', 'model to use')
.action(async options => {
if (options.model === 'claude')
await run(new ClaudeDelegate());
else
await run(new OpenAIDelegate());
});
void program.parseAsync(process.argv);

View File

@@ -1,5 +0,0 @@
[*]
../
../loop/
../mcp/
../utils/

View File

@@ -1,79 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { contextFactory } from '../browserContextFactory.js';
import { BrowserServerBackend } from '../browserServerBackend.js';
import { Context as BrowserContext } from '../context.js';
import { runTask } from '../loop/loop.js';
import { OpenAIDelegate } from '../loop/loopOpenAI.js';
import { ClaudeDelegate } from '../loop/loopClaude.js';
import { InProcessTransport } from '../mcp/inProcessTransport.js';
import * as mcpServer from '../mcp/server.js';
import { packageJSON } from '../utils/package.js';
import * as mcpBundle from '../mcp/bundle.js';
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
import type { LLMDelegate } from '../loop/loop.js';
import type { FullConfig } from '../config.js';
export class Context {
readonly config: FullConfig;
private _client: Client;
private _delegate: LLMDelegate;
constructor(config: FullConfig, client: Client) {
this.config = config;
this._client = client;
if (process.env.OPENAI_API_KEY)
this._delegate = new OpenAIDelegate();
else if (process.env.ANTHROPIC_API_KEY)
this._delegate = new ClaudeDelegate();
else
throw new Error('No LLM API key found. Please set OPENAI_API_KEY or ANTHROPIC_API_KEY environment variable.');
}
static async create(config: FullConfig) {
const client = new mcpBundle.Client({ name: 'Playwright Proxy', version: packageJSON.version });
const browserContextFactory = contextFactory(config);
const server = mcpServer.createServer('Playwright Subagent', packageJSON.version, new BrowserServerBackend(config, browserContextFactory), false);
await client.connect(new InProcessTransport(server));
await client.ping();
return new Context(config, client);
}
async runTask(task: string, oneShot: boolean = false): Promise<mcpServer.CallToolResult> {
const messages = await runTask(this._delegate, this._client!, task, oneShot);
const lines: string[] = [];
// Skip the first message, which is the user's task.
for (const message of messages.slice(1)) {
// Trim out all page snapshots.
if (!message.content.trim())
continue;
const index = oneShot ? -1 : message.content.indexOf('### Page state');
const trimmedContent = index === -1 ? message.content : message.content.substring(0, index);
lines.push(`[${message.role}]:`, trimmedContent);
}
return {
content: [{ type: 'text', text: lines.join('\n') }],
};
}
async close() {
await BrowserContext.disposeAll();
}
}

View File

@@ -1,67 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import dotenv from 'dotenv';
import * as mcpServer from '../mcp/server.js';
import { packageJSON } from '../utils/package.js';
import { Context } from './context';
import { perform } from './perform';
import { snapshot } from './snapshot';
import { toMcpTool } from '../mcp/tool.js';
import type { FullConfig } from '../config.js';
import type { ServerBackend } from '../mcp/server.js';
import type { Tool } from './tool';
export async function runLoopTools(config: FullConfig) {
dotenv.config();
const serverBackendFactory = {
name: 'Playwright',
nameInConfig: 'playwright-loop',
version: packageJSON.version,
create: () => new LoopToolsServerBackend(config)
};
await mcpServer.start(serverBackendFactory, config.server);
}
class LoopToolsServerBackend implements ServerBackend {
private _config: FullConfig;
private _context: Context | undefined;
private _tools: Tool<any>[] = [perform, snapshot];
constructor(config: FullConfig) {
this._config = config;
}
async initialize() {
this._context = await Context.create(this._config);
}
async listTools(): Promise<mcpServer.Tool[]> {
return this._tools.map(tool => toMcpTool(tool.schema));
}
async callTool(name: string, args: mcpServer.CallToolRequest['params']['arguments']): Promise<mcpServer.CallToolResult> {
const tool = this._tools.find(tool => tool.schema.name === name)!;
const parsedArguments = tool.schema.inputSchema.parse(args || {});
return await tool.handle(this._context!, parsedArguments);
}
serverClosed() {
void this._context!.close();
}
}

View File

@@ -1,36 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { z } from '../mcp/bundle.js';
import { defineTool } from './tool.js';
const performSchema = z.object({
task: z.string().describe('The task to perform with the browser'),
});
export const perform = defineTool({
schema: {
name: 'browser_perform',
title: 'Perform a task with the browser',
description: 'Perform a task with the browser. It can click, type, export, capture screenshot, drag, hover, select options, etc.',
inputSchema: performSchema,
type: 'destructive',
},
handle: async (context, params) => {
return await context.runTask(params.task);
},
});

View File

@@ -1,32 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { z } from '../mcp/bundle.js';
import { defineTool } from './tool.js';
export const snapshot = defineTool({
schema: {
name: 'browser_snapshot',
title: 'Take a snapshot of the browser',
description: 'Take a snapshot of the browser to read what is on the page.',
inputSchema: z.object({}),
type: 'readOnly',
},
handle: async (context, params) => {
return await context.runTask('Capture browser snapshot', true);
},
});

View File

@@ -1,30 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { z } from 'zod';
import type * as mcpServer from '../mcp/server.js';
import type { Context } from './context';
import type { ToolSchema } from '../mcp/tool.js';
export type Tool<Input extends z.Schema = z.Schema> = {
schema: ToolSchema<Input>;
handle: (context: Context, params: z.output<Input>) => Promise<mcpServer.CallToolResult>;
};
export function defineTool<Input extends z.Schema>(tool: Tool<Input>): Tool<Input> {
return tool;
}

View File

@@ -17,4 +17,4 @@
import fs from 'fs';
import path from 'path';
export const packageJSON = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8'));
export const packageJSON = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));

View File

@@ -15,18 +15,17 @@
*/
import { program, Option } from 'commander';
import * as mcpServer from './mcp/server';
import { commaSeparatedList, resolveCLIConfig, semicolonSeparatedList } from './config';
import { packageJSON } from './utils/package';
import { Context } from './context';
import { contextFactory } from './browserContextFactory';
import { runLoopTools } from './loopTools/main';
import { ProxyBackend } from './mcp/proxyBackend';
import { BrowserServerBackend } from './browserServerBackend';
import * as mcpServer from './sdk/server';
import { commaSeparatedList, resolveCLIConfig, semicolonSeparatedList } from './browser/config';
import { packageJSON } from './package';
import { Context } from './browser/context';
import { contextFactory } from './browser/browserContextFactory';
import { ProxyBackend } from './sdk/proxyBackend';
import { BrowserServerBackend } from './browser/browserServerBackend';
import { ExtensionContextFactory } from './extension/extensionContextFactory';
import { runVSCodeTools } from './vscode/host';
import type { MCPProvider } from './mcp/proxyBackend';
import type { MCPProvider } from './sdk/proxyBackend';
program
.version('Version ' + packageJSON.version)
@@ -59,7 +58,6 @@ program
.option('--viewport-size <size>', 'specify browser viewport size in pixels, for example "1280, 720"')
.addOption(new Option('--connect-tool', 'Allow to switch between different browser connection methods.').hideHelp())
.addOption(new Option('--vscode', 'VS Code tools.').hideHelp())
.addOption(new Option('--loop-tools', 'Run loop tools').hideHelp())
.addOption(new Option('--vision', 'Legacy option, use --caps=vision instead').hideHelp())
.action(async options => {
setupExitWatchdog();
@@ -90,11 +88,6 @@ program
return;
}
if (options.loopTools) {
await runLoopTools(config);
return;
}
if (options.connectTool) {
const providers: MCPProvider[] = [
{

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { zodToJsonSchema } from '../mcp/bundle.js';
import { zodToJsonSchema } from '../sdk/bundle';
import type { z } from 'zod';
import type * as mcpServer from './server';

View File

@@ -1,3 +0,0 @@
[*]
../utils/
../mcp/

View File

@@ -1,39 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import os from 'os';
import path from 'path';
export function cacheDir() {
let cacheDirectory: string;
if (process.platform === 'linux')
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
else if (process.platform === 'darwin')
cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
else if (process.platform === 'win32')
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
else
throw new Error('Unsupported platform: ' + process.platform);
return path.join(cacheDirectory, 'ms-playwright');
}
export function sanitizeForFilePath(s: string) {
const sanitize = (s: string) => s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
const separator = s.lastIndexOf('.');
if (separator === -1)
return sanitize(s);
return sanitize(s.substring(0, separator)) + '.' + sanitize(s.substring(separator + 1));
}

View File

@@ -1,25 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import crypto from 'crypto';
export function createGuid(): string {
return crypto.randomBytes(16).toString('hex');
}
export function createHash(data: string): string {
return crypto.createHash('sha256').update(data).digest('hex').slice(0, 7);
}

View File

@@ -1,6 +1,7 @@
[*]
../mcp/
../utils/
../config.js
../browserServerBackend.js
../browserContextFactory.js
../sdk/
../browser/config.ts
../browser/browserServerBackend.ts
../browser/browserContextFactory.ts
../log.ts
../package.ts

View File

@@ -16,18 +16,18 @@
import path from 'path';
import * as mcpBundle from '../mcp/bundle.js';
import * as mcpServer from '../mcp/server.js';
import { logUnhandledError } from '../utils/log.js';
import { packageJSON } from '../utils/package.js';
import * as mcpBundle from '../sdk/bundle';
import * as mcpServer from '../sdk/server';
import { logUnhandledError } from '../log';
import { packageJSON } from '../package';
import { FullConfig } from '../config.js';
import { BrowserServerBackend } from '../browserServerBackend.js';
import { contextFactory } from '../browserContextFactory.js';
import { FullConfig } from '../browser/config';
import { BrowserServerBackend } from '../browser/browserServerBackend';
import { contextFactory } from '../browser/browserContextFactory';
import type { z as zod } from 'zod';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import type { ClientVersion, ServerBackend } from '../mcp/server.js';
import type { ClientVersion, ServerBackend } from '../sdk/server';
import type { Root, Tool, CallToolResult, CallToolRequest } from '@modelcontextprotocol/sdk/types.js';
import type { Browser, BrowserContext, BrowserServer } from 'playwright';
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';

View File

@@ -14,12 +14,12 @@
* limitations under the License.
*/
import * as mcpBundle from '../mcp/bundle.js';
import * as mcpServer from '../mcp/server.js';
import { BrowserServerBackend } from '../browserServerBackend.js';
import { BrowserContextFactory, ClientInfo } from '../browserContextFactory.js';
import * as mcpBundle from '../sdk/bundle';
import * as mcpServer from '../sdk/server';
import { BrowserServerBackend } from '../browser/browserServerBackend';
import { BrowserContextFactory, ClientInfo } from '../browser/browserContextFactory';
import type { FullConfig } from '../config.js';
import type { FullConfig } from '../browser/config';
import type { BrowserContext } from 'playwright-core';
class VSCodeBrowserContextFactory implements BrowserContextFactory {

View File

@@ -16,8 +16,9 @@
import fs from 'node:fs';
import { Config } from '../config.js';
import { Config } from '../config';
import { test, expect } from './fixtures';
import { configFromCLIOptions } from '../lib/browser/config';
test('config user data dir', async ({ startClient, server, mcpMode }, testInfo) => {
server.setContent('/', `
@@ -68,17 +69,12 @@ test.describe(() => {
test.describe('sandbox configuration', () => {
test('should enable sandbox by default (no --no-sandbox flag)', async () => {
const { configFromCLIOptions } = await import('../lib/config.js');
const config = configFromCLIOptions({ sandbox: undefined });
// When --no-sandbox is not passed, chromiumSandbox should not be set to false
// This allows the default (true) to be used
expect(config.browser?.launchOptions?.chromiumSandbox).toBeUndefined();
});
test('should disable sandbox when --no-sandbox flag is passed', async () => {
const { configFromCLIOptions } = await import('../lib/config.js');
const config = configFromCLIOptions({ sandbox: false });
// When --no-sandbox is passed, chromiumSandbox should be explicitly set to false
expect(config.browser?.launchOptions?.chromiumSandbox).toBe(false);
});
});

View File

@@ -19,12 +19,12 @@ import zodToJsonSchema from 'zod-to-json-schema';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { runMainBackend, runOnPauseBackendLoop } from '../src/mcp/mdb.js';
import { runMainBackend, runOnPauseBackendLoop } from '../src/sdk/mdb';
import { test, expect } from './fixtures';
import type * as mcpServer from '../src/mcp/server.js';
import type { ServerBackendOnPause } from '../src/mcp/mdb.js';
import type * as mcpServer from '../src/sdk/server';
import type { ServerBackendOnPause } from '../src/sdk/mdb';
test('call top level tool', async () => {
const { mdbUrl } = await startMDBAndCLI();

View File

@@ -14,12 +14,12 @@
* limitations under the License.
*/
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { pathToFileURL } from 'url';
import { test, expect } from './fixtures';
import { createHash } from '../src/utils/guid.js';
const p = process.platform === 'win32' ? 'c:\\non\\existent\\folder' : '/non/existent/folder';
@@ -77,3 +77,7 @@ test('should list all tools when listRoots is slow', async ({ startClient }) =>
const tools = await client.listTools();
expect(tools.tools.length).toBeGreaterThan(10);
});
function createHash(data: string): string {
return crypto.createHash('sha256').update(data).digest('hex').slice(0, 7);
}

View File

@@ -21,7 +21,7 @@ const path = require('path')
const { zodToJsonSchema } = require('zod-to-json-schema')
const { execSync } = require('child_process');
const { allTools } = require('../lib/tools.js');
const { allTools } = require('../lib/browser/tools.js');
const capabilities = {
'core': 'Core automation',
@@ -35,7 +35,7 @@ const capabilities = {
const toolsByCapability = Object.fromEntries(Object.entries(capabilities).map(([capability, title]) => [title, allTools.filter(tool => tool.capability === capability).sort((a, b) => a.schema.name.localeCompare(b.schema.name))]));
/**
* @param {import('../src/mcp/tool.js').ToolSchema<any>} tool
* @param {import('../src/sdk/tool.js').ToolSchema<any>} tool
* @returns {string[]}
*/
function formatToolForReadme(tool) {