From a6dd5adbe52b88c5e8c415c2d1f397cd7751b71e Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Fri, 11 Sep 2026 08:54:46 -0700 Subject: [PATCH] feat(mcp): add --file-paths=absolute for absolute paths in tool results Fixes: https://github.com/microsoft/playwright/issues/42497 --- .../src/tools/backend/context.ts | 1 + .../src/tools/backend/response.ts | 15 +++--- .../playwright-core/src/tools/mcp/config.d.ts | 5 ++ .../playwright-core/src/tools/mcp/config.ts | 4 ++ .../src/tools/mcp/configIni.ts | 1 + .../playwright-core/src/tools/mcp/program.ts | 1 + tests/mcp/file-paths.spec.ts | 46 +++++++++++++++++++ 7 files changed, 67 insertions(+), 6 deletions(-) create mode 100644 tests/mcp/file-paths.spec.ts diff --git a/packages/playwright-core/src/tools/backend/context.ts b/packages/playwright-core/src/tools/backend/context.ts index 05b22548a3059..43a76660f3360 100644 --- a/packages/playwright-core/src/tools/backend/context.ts +++ b/packages/playwright-core/src/tools/backend/context.ts @@ -43,6 +43,7 @@ export type ContextConfig = { codegen?: 'typescript' | 'python' | 'java' | 'csharp' | 'none'; console?: { level?: 'error' | 'warning' | 'info' | 'debug' }; imageResponses?: 'allow' | 'omit' | 'only'; + filePaths?: 'relative' | 'absolute'; network?: { allowedOrigins?: string[]; blockedOrigins?: string[]; diff --git a/packages/playwright-core/src/tools/backend/response.ts b/packages/playwright-core/src/tools/backend/response.ts index 1c34ee11ce257..fe2225bb6d51e 100644 --- a/packages/playwright-core/src/tools/backend/response.ts +++ b/packages/playwright-core/src/tools/backend/response.ts @@ -76,7 +76,9 @@ export class Response { this._raw = this._json || (options?.raw ?? false); } - private _computeRelativeTo(fileName: string): string { + private _printablePath(fileName: string): string { + if (this._context.config.filePaths === 'absolute') + return path.resolve(fileName); const rel = path.relative(this._clientWorkspace, fileName); // Prefix bare filenames with `./` so they're not mistaken for living in // the auto-named `.playwright-cli/` artifact directory. @@ -92,7 +94,7 @@ export class Response { else fileName = await this._context.outputFile(template, { origin: 'llm' }); await fs.promises.mkdir(path.dirname(fileName), { recursive: true }); - const relativeName = this._computeRelativeTo(fileName); + const relativeName = this._printablePath(fileName); const printableLink = `- [${title}](${relativeName})`; return { fileName, relativeName, printableLink }; } @@ -128,7 +130,7 @@ export class Response { } addFileLink(title: string, fileName: string) { - const relativeName = this._computeRelativeTo(fileName); + const relativeName = this._printablePath(fileName); this.addTextResult(`- [${title}](${relativeName})`); } @@ -285,7 +287,8 @@ export class Response { const snapshotToFile = this._includeSnapshot !== 'explicit' || !!this._includeSnapshotFileName; const ariaFormat = this._includeSnapshot === 'none' ? 'none' : (this._json && !snapshotToFile ? 'json' : 'text'); const updateWebMCP = this._includeSnapshot !== 'none'; // Collect the page's WebMCP tools whenever a snapshot is taken anyway. - const tabSnapshot = this._context.currentTab() ? await this._context.currentTabOrDie().captureSnapshot(this._includeSnapshotRoot, this._includeSnapshotDepth, this._includeSnapshotBoxes, this._clientWorkspace, ariaFormat, updateWebMCP) : undefined; + const logRelativeTo = this._context.config.filePaths === 'absolute' ? undefined : this._clientWorkspace; + const tabSnapshot = this._context.currentTab() ? await this._context.currentTabOrDie().captureSnapshot(this._includeSnapshotRoot, this._includeSnapshotDepth, this._includeSnapshotBoxes, logRelativeTo, ariaFormat, updateWebMCP) : undefined; const tabHeaders = await Promise.all(this._context.tabs().map(tab => tab.headerSnapshot())); if (this._includeSnapshot !== 'none' || tabHeaders.some(header => header.changed)) { if (tabHeaders.length !== 1) @@ -320,7 +323,7 @@ export class Response { if (event.type === 'download-start') text.push(`- Downloading file ${event.download.download.suggestedFilename()} ...`); else if (event.type === 'download-finish') - text.push(`- Downloaded file ${event.download.download.suggestedFilename()} to "${this._computeRelativeTo(event.download.outputFile)}"`); + text.push(`- Downloaded file ${event.download.download.suggestedFilename()} to "${this._printablePath(event.download.outputFile)}"`); } } if (text.length) @@ -329,7 +332,7 @@ export class Response { const pausedDetails = this._context.debugger().pausedDetails(); if (pausedDetails) { addSection('Paused', [ - `- ${pausedDetails.title} at ${this._computeRelativeTo(pausedDetails.location.file)}${pausedDetails.location.line ? ':' + pausedDetails.location.line : ''}`, + `- ${pausedDetails.title} at ${this._printablePath(pausedDetails.location.file)}${pausedDetails.location.line ? ':' + pausedDetails.location.line : ''}`, '- Use any tools to explore and interact, resume by calling resume/step-over/pause-at', ]); } diff --git a/packages/playwright-core/src/tools/mcp/config.d.ts b/packages/playwright-core/src/tools/mcp/config.d.ts index 421a601c25bfd..4a6c445820a00 100644 --- a/packages/playwright-core/src/tools/mcp/config.d.ts +++ b/packages/playwright-core/src/tools/mcp/config.d.ts @@ -232,6 +232,11 @@ export type Config = { */ imageResponses?: 'allow' | 'omit' | 'only'; + /** + * How file paths are rendered in tool results. Can be "relative" to the workspace root or "absolute". Defaults to "relative". + */ + filePaths?: 'relative' | 'absolute'; + snapshot?: { /** * When taking snapshots for responses, specifies the mode to use. diff --git a/packages/playwright-core/src/tools/mcp/config.ts b/packages/playwright-core/src/tools/mcp/config.ts index cb7f1d1ea11d8..63cdaffbea94d 100644 --- a/packages/playwright-core/src/tools/mcp/config.ts +++ b/packages/playwright-core/src/tools/mcp/config.ts @@ -50,6 +50,7 @@ export type CLIOptions = { endpoint?: string; extension?: boolean; executablePath?: string; + filePaths?: 'relative' | 'absolute'; grantPermissions?: string[]; headless?: boolean; host?: string; @@ -390,6 +391,7 @@ function configFromCLIOptions(cliOptions: CLIOptions): Config & { configFile?: s outputDir: cliOptions.outputDir, outputMaxSize: cliOptions.outputMaxSize, imageResponses: cliOptions.imageResponses, + filePaths: cliOptions.filePaths, testIdAttribute: cliOptions.testIdAttribute, timeouts: { action: cliOptions.timeoutAction, @@ -428,6 +430,8 @@ export function configFromEnv(env?: NodeJS.ProcessEnv): Config & { configFile?: options.device = envToString(e.PLAYWRIGHT_MCP_DEVICE); options.executablePath = envToString(e.PLAYWRIGHT_MCP_EXECUTABLE_PATH); options.extension = envToBoolean(e.PLAYWRIGHT_MCP_EXTENSION); + if (e.PLAYWRIGHT_MCP_FILE_PATHS) + options.filePaths = enumParser<'relative' | 'absolute'>('--file-paths', ['relative', 'absolute'], e.PLAYWRIGHT_MCP_FILE_PATHS); options.grantPermissions = commaSeparatedList(e.PLAYWRIGHT_MCP_GRANT_PERMISSIONS); options.headless = envToBoolean(e.PLAYWRIGHT_MCP_HEADLESS); options.host = envToString(e.PLAYWRIGHT_MCP_HOST); diff --git a/packages/playwright-core/src/tools/mcp/configIni.ts b/packages/playwright-core/src/tools/mcp/configIni.ts index acb76b0ec7072..c6e952ceb60c4 100644 --- a/packages/playwright-core/src/tools/mcp/configIni.ts +++ b/packages/playwright-core/src/tools/mcp/configIni.ts @@ -162,6 +162,7 @@ const longhandTypes: Record = { 'outputDir': 'string', 'outputMaxSize': 'number', 'imageResponses': 'string', + 'filePaths': 'string', 'allowUnrestrictedFileAccess': 'boolean', 'codegen': 'string', 'testIdAttribute': 'string', diff --git a/packages/playwright-core/src/tools/mcp/program.ts b/packages/playwright-core/src/tools/mcp/program.ts index 848be4c699307..03a780c2fda7e 100644 --- a/packages/playwright-core/src/tools/mcp/program.ts +++ b/packages/playwright-core/src/tools/mcp/program.ts @@ -51,6 +51,7 @@ export function decorateMCPCommand(command: Command) { .option('--executable-path ', 'path to the browser executable.') .option('--extension', 'Connect to a running browser instance (Edge/Chrome only). Requires the "Playwright Extension" to be installed.') .option('--endpoint ', 'Bound browser endpoint to connect to.') + .option('--file-paths ', 'how file paths are rendered in tool results, "relative" to the workspace root or "absolute". Default is "relative".', enumParser.bind(null, '--file-paths', ['relative', 'absolute'])) .option('--grant-permissions ', 'List of permissions to grant to the browser context, for example "geolocation", "clipboard-read", "clipboard-write".', commaSeparatedList) .option('--headless', 'run browser in headless mode, headed by default') .option('--host ', 'host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.') diff --git a/tests/mcp/file-paths.spec.ts b/tests/mcp/file-paths.spec.ts new file mode 100644 index 0000000000000..c7c359f888092 --- /dev/null +++ b/tests/mcp/file-paths.spec.ts @@ -0,0 +1,46 @@ +/** + * 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 path from 'path'; +import { test, expect, parseResponse } from './fixtures'; + +test('--file-paths=absolute', async ({ startClient, server }, testInfo) => { + const outputDir = testInfo.outputPath('output'); + const { client } = await startClient({ + args: ['--file-paths=absolute'], + config: { outputDir }, + }); + server.setContent('/', ``, 'text/html'); + + const navigate = parseResponse(await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.PREFIX }, + })); + expect(navigate.events).toContain(`New console entries: ${path.join(outputDir, 'console-')}`); + + expect(await client.callTool({ + name: 'browser_take_screenshot', + })).toHaveResponse({ + result: expect.stringContaining(`[Screenshot of viewport](${path.join(outputDir, 'page-')}`), + }); + + expect(await client.callTool({ + name: 'browser_take_screenshot', + arguments: { filename: 'screenshot.png' }, + })).toHaveResponse({ + result: expect.stringContaining(`[Screenshot of viewport](${testInfo.outputPath('screenshot.png')})`), + }); +});