Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/playwright-core/src/tools/mcp/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export function decorateMCPCommand(command: Command) {
command
.option('--allowed-hosts <hosts...>', 'comma-separated list of hosts this server is allowed to serve from. Defaults to the host the server is bound to. Pass \'*\' to disable the host check.', commaSeparatedList)
.option('--allowed-origins <origins>', 'semicolon-separated list of TRUSTED origins to allow the browser to request. Default is to allow all.\nImportant: *does not* serve as a security boundary and *does not* affect redirects. ', semicolonSeparatedList)
.option('--allow-unrestricted-file-access', 'allow access to files outside of the workspace roots. Also allows unrestricted access to file:// URLs. By default access to file system is restricted to workspace root directories (or cwd if no roots are configured) only, and navigation to file:// URLs is blocked.')
.option('--allow-unrestricted-file-access', 'allow access to files outside of the workspace directory. Also allows unrestricted access to file:// URLs. By default access to file system is restricted to the current working directory only, and navigation to file:// URLs is blocked.')
.option('--blocked-origins <origins>', 'semicolon-separated list of origins to block the browser from requesting. Blocklist is evaluated before allowlist. If used without the allowlist, requests not matching the blocklist are still allowed.\nImportant: *does not* serve as a security boundary and *does not* affect redirects.', semicolonSeparatedList)
.option('--block-service-workers', 'block service workers')
.option('--browser <browser>', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
Expand Down
43 changes: 3 additions & 40 deletions packages/playwright-core/src/tools/utils/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,17 @@
* limitations under the License.
*/

import { fileURLToPath } from 'url';

import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import debug from 'debug';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { startMcpHttpServer } from './http';
import { toMcpTool } from './tool';

import type { CallToolResult, CallToolRequest, Root } from '@modelcontextprotocol/sdk/types.js';
import type { CallToolResult, CallToolRequest } from '@modelcontextprotocol/sdk/types.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
export type { Server } from '@modelcontextprotocol/sdk/server/index.js';
export type { Tool, CallToolResult, CallToolRequest, Root } from '@modelcontextprotocol/sdk/types.js';
export type { Tool, CallToolResult, CallToolRequest } from '@modelcontextprotocol/sdk/types.js';
import type { Server as ServerType } from '@modelcontextprotocol/sdk/server/index.js';
import type { ToolSchema } from './tool';

Expand Down Expand Up @@ -130,18 +128,8 @@ export function createServer(name: string, version: string, factory: ServerBacke
}

const initializeServer = async (server: ServerType, factory: ServerBackendFactory, runHeartbeat: boolean): Promise<ServerBackend> => {
const capabilities = server.getClientCapabilities();
let clientRoots: Root[] = [];
if (capabilities?.roots) {
const { roots } = await server.listRoots().catch(e => {
serverDebug(e);
return { roots: [] };
});
clientRoots = roots;
}

const clientInfo: ClientInfo = {
cwd: firstRootPath(clientRoots),
cwd: process.cwd(),
clientName: server.getClientVersion()?.name ?? 'Playwright MCP',
};

Expand Down Expand Up @@ -216,31 +204,6 @@ export async function start(serverBackendFactory: ServerBackendFactory, options:
console.error(message);
}

export function firstRootPath(roots: Root[]): string {
return allRootPaths(roots)[0];
}

export function allRootPaths(roots: Root[]): string[] {
const paths: string[] = [];
for (const root of roots) {
const url = new URL(root.uri);
let rootPath;
try {
rootPath = fileURLToPath(url);
} catch (e) {
// Support WSL paths on Windows.
if (e.code === 'ERR_INVALID_FILE_URL_PATH' && process.platform === 'win32')
rootPath = decodeURIComponent(url.pathname);
}
if (!rootPath)
continue;
paths.push(rootPath);
}
if (paths.length === 0)
paths.push(process.cwd());
return paths;
}

function mergeTextParts(result: CallToolResult): CallToolResult {
const content: CallToolResult['content'] = [];
const testParts: string[] = [];
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright-core/src/utilsBundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,5 +113,5 @@ export { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
export { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
export { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
export { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
export { CallToolRequestSchema, ListRootsRequestSchema, ListToolsRequestSchema, PingRequestSchema, ProgressNotificationSchema } from '@modelcontextprotocol/sdk/types.js';
export { CallToolRequestSchema, ListToolsRequestSchema, PingRequestSchema, ProgressNotificationSchema } from '@modelcontextprotocol/sdk/types.js';
export { zodToJsonSchema } from 'zod-to-json-schema';
7 changes: 0 additions & 7 deletions tests/mcp/core.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,19 +61,12 @@ test('browser_navigate allows about:, data: and javascript: protocols', async ({
});

test('browser_navigate can navigate to file:// URLs allowUnrestrictedFileAccess is true', async ({ startClient }, testInfo) => {
const rootDir = testInfo.outputPath();
const fileOutsideRoot = testInfo.outputPath('test.txt');
await fs.writeFile(fileOutsideRoot, 'Test file content');
const { client } = await startClient({
config: {
allowUnrestrictedFileAccess: true,
},
roots: [
{
name: 'workspace',
uri: pathToFileURL(rootDir).href,
}
],
});

const url = pathToFileURL(fileOutsideRoot).href;
Expand Down
79 changes: 1 addition & 78 deletions tests/mcp/files.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,75 +161,7 @@ test('navigating to download link emits download', async ({ startClient, server,
});
});

test('file upload restricted to roots by default', async ({ startClient, server }, testInfo) => {
const rootDir = testInfo.outputPath('workspace');
await fs.mkdir(rootDir, { recursive: true });

const { client } = await startClient({
roots: [
{
name: 'workspace',
uri: `file://${rootDir}`,
}
],
});

server.setContent('/', `<input type="file" />`, 'text/html');

await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});

// Click on file input to trigger file chooser
await client.callTool({
name: 'browser_click',
arguments: {
element: 'Textbox',
target: 'e2',
},
});

// Create a file inside the root
const fileInsideRoot = testInfo.outputPath('workspace', 'inside.txt');
await fs.writeFile(fileInsideRoot, 'Inside root');

// Should succeed - file is inside root
expect(await client.callTool({
name: 'browser_file_upload',
arguments: {
paths: [fileInsideRoot],
},
})).toHaveResponse({
code: expect.stringContaining(`await fileChooser.setFiles(`),
});

// Click again to open file chooser
await client.callTool({
name: 'browser_click',
arguments: {
element: 'Textbox',
target: 'e2',
},
});

// Create a file outside the root
const fileOutsideRoot = testInfo.outputPath('outside.txt');
await fs.writeFile(fileOutsideRoot, 'Outside root');

// Should fail - file is outside root
expect(await client.callTool({
name: 'browser_file_upload',
arguments: {
paths: [fileOutsideRoot],
},
})).toHaveResponse({
isError: true,
error: expect.stringMatching('File access denied: .* is outside allowed roots'),
});
});

test('file upload is restricted to cwd if no roots are configured', async ({ startClient, server }, testInfo) => {
test('file upload is restricted to cwd', async ({ startClient, server }, testInfo) => {
const rootDir = testInfo.outputPath('workspace');
await fs.mkdir(rootDir, { recursive: true });

Expand Down Expand Up @@ -291,19 +223,10 @@ test('file upload is restricted to cwd if no roots are configured', async ({ sta
});

test('file upload unrestricted when flag is set', async ({ startClient, server }, testInfo) => {
const rootDir = testInfo.outputPath('workspace');
await fs.mkdir(rootDir, { recursive: true });

const { client } = await startClient({
config: {
allowUnrestrictedFileAccess: true,
},
roots: [
{
name: 'workspace',
uri: `file://${rootDir}`,
}
],
});

server.setContent('/', `<input type="file" />`, 'text/html');
Expand Down
14 changes: 1 addition & 13 deletions tests/mcp/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import { chromium } from 'playwright';
import { test as baseTest, expect as baseExpect } from '@playwright/test';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { ListRootsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { TestServer } from '../config/testserver';
import { serverFixtures } from '../config/serverFixtures';
import { tools } from '../../packages/playwright-core/lib/coreBundle';
Expand Down Expand Up @@ -57,8 +56,6 @@ export type StartClient = (options?: {
omitArgs?: string[],
cwd?: string,
config?: Config | string,
roots?: { name: string, uri: string }[],
rootsResponseDelay?: number,
env?: NodeJS.ProcessEnv,
noTimeoutForTest?: boolean,
}) => Promise<{ client: Client, stderr: () => string }>;
Expand Down Expand Up @@ -127,16 +124,7 @@ export const test = serverTest.extend<TestFixtures & TestOptions, WorkerFixtures
if (options?.omitArgs)
args = args.filter(arg => !options.omitArgs?.includes(arg));

const client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' }, options?.roots ? { capabilities: { roots: {} } } : undefined);
if (options?.roots) {
client.setRequestHandler(ListRootsRequestSchema, async request => {
if (options.rootsResponseDelay)
await new Promise(resolve => setTimeout(resolve, options.rootsResponseDelay));
return {
roots: options.roots,
};
});
}
const client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' });
const env = inheritAndCleanEnv({
PW_TMPDIR_FOR_TEST: testInfo.outputPath('tmp'),
...options?.env
Expand Down
26 changes: 1 addition & 25 deletions tests/mcp/http.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { test as baseTest, expect, mcpServerPath, formatLog } from './fixtures';
import { inheritAndCleanEnv } from '../config/utils';

import type { Config } from '../../packages/playwright-core/src/tools/mcp/config.d';
import { ListRootsRequestSchema, PingRequestSchema } from 'playwright-core/lib/utilsBundle';
import { PingRequestSchema } from 'playwright-core/lib/utilsBundle';

const test = baseTest.extend<{ serverEndpoint: (options?: { args?: string[], noPort?: boolean, env?: Record<string, string> }) => Promise<{ url: URL, stderr: () => string }> }>({
serverEndpoint: async ({ mcpHeadless }, use, testInfo) => {
Expand Down Expand Up @@ -346,30 +346,6 @@ test('http transport (default)', async ({ serverEndpoint }) => {
expect(transport.sessionId, 'has session support').toBeDefined();
});

test('client should receive list roots request', async ({ serverEndpoint, server }) => {
const { url } = await serverEndpoint();
const transport = new StreamableHTTPClientTransport(url);
const client = new Client({ name: 'test', version: '1.0.0' }, { capabilities: { roots: {} } });
const requests = [];
client.setRequestHandler(ListRootsRequestSchema, async request => {
requests.push(request);
return {
roots: [
{
name: 'test',
uri: 'file://tmp/',
}
],
};
});
await client.connect(transport);
await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
});
await expect.poll(() => requests).toEqual([{ method: 'roots/list' }]);
});

test('should close session when heartbeat ping is not answered', async ({ serverEndpoint, server }) => {
const { url, stderr } = await serverEndpoint({ env: { PLAYWRIGHT_MCP_PING_TIMEOUT_MS: '500' } });

Expand Down
3 changes: 1 addition & 2 deletions tests/mcp/planner.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import { test, expect, writeFiles } from './fixtures';

import fs from 'fs';
import path from 'path';
import url from 'url';

test.use({ mcpServerType: 'test-mcp' });

Expand Down Expand Up @@ -117,7 +116,7 @@ test('planner_setup_page seed resolution - rootPath', async ({ startClient }) =>

const { client } = await startClient({
args: ['--config=packages/my-app/configs/playwright.config.ts'],
roots: [{ name: 'root', uri: url.pathToFileURL(test.info().outputPath('')).toString() }],
cwd: test.info().outputPath(''),
});

expect(await client.callTool({
Expand Down
Loading
Loading