mirror of
https://github.com/ChromeDevTools/chrome-devtools-mcp.git
synced 2026-11-04 15:00:11 +00:00
feat: add experimental screencast recording tools (#941)
## Summary Adds `screencast_start` and `screencast_stop` MCP tools that allow agents to record a video of a page using Puppeteer's `page.screencast()` API. Only enabled if the command line option `--experimental-screencast` is provided Closes #878. ## New Tools ### `screencast_start` Starts recording a screencast (video) of the selected page in mp4 format. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `path` | string (optional) | temp file | Output file path | ### `screencast_stop` Stops the active recording and reports the saved file path. ## Design - **Start/stop pair** following the `performance_start_trace` / `performance_stop_trace` pattern - **State management** via `getScreenRecorder()` / `setScreenRecorder()` on the Context interface - **Category**: `DEBUGGING` (alongside `take_screenshot`) - **ffmpeg dependency**: Clear error message when ffmpeg is not installed - **Temp file fallback**: When no `path` is provided, creates a temp file with the correct extension --------- Co-authored-by: Alex Rudenko <alexrudenko@chromium.org>
This commit is contained in:
1 parent
5696cce035
commit
33446d457e
8 files changed
+298
No files matched your search
@@ -495,6 +495,10 @@ The Chrome DevTools MCP server supports the following configuration option:
|
||||
If enabled, ignores errors relative to self-signed and expired certificates. Use with caution.
|
||||
- **Type:** boolean
|
||||
|
||||
- **`--experimentalScreencast`/ `--experimental-screencast`**
|
||||
Exposes experimental screencast tools (requires ffmpeg). Install ffmpeg https://www.ffmpeg.org/download.html and ensure it is available in the MCP server PATH.
|
||||
- **Type:** boolean
|
||||
|
||||
- **`--chromeArg`/ `--chrome-arg`**
|
||||
Additional arguments for Chrome. Only applies when Chrome is launched by chrome-devtools-mcp.
|
||||
- **Type:** array
|
||||
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
ElementHandle,
|
||||
HTTPRequest,
|
||||
Page,
|
||||
ScreenRecorder,
|
||||
SerializedAXNode,
|
||||
Viewport,
|
||||
} from './third_party/index.js';
|
||||
@@ -130,6 +131,8 @@ export class McpContext implements Context {
|
||||
#extensionRegistry = new ExtensionRegistry();
|
||||
|
||||
#isRunningTrace = false;
|
||||
#screenRecorderData: {recorder: ScreenRecorder; filePath: string} | null =
|
||||
null;
|
||||
#emulationSettingsMap = new WeakMap<Page, EmulationSettings>();
|
||||
#dialog?: Dialog;
|
||||
|
||||
@@ -436,6 +439,16 @@ export class McpContext implements Context {
|
||||
return this.#isRunningTrace;
|
||||
}
|
||||
|
||||
getScreenRecorder(): {recorder: ScreenRecorder; filePath: string} | null {
|
||||
return this.#screenRecorderData;
|
||||
}
|
||||
|
||||
setScreenRecorder(
|
||||
data: {recorder: ScreenRecorder; filePath: string} | null,
|
||||
): void {
|
||||
this.#screenRecorderData = data;
|
||||
}
|
||||
|
||||
isCruxEnabled(): boolean {
|
||||
return this.#options.performanceCrux;
|
||||
}
|
||||
|
||||
@@ -173,6 +173,11 @@ export const cliOptions = {
|
||||
describe: 'Whether to enable interoperability tools',
|
||||
hidden: true,
|
||||
},
|
||||
experimentalScreencast: {
|
||||
type: 'boolean',
|
||||
describe:
|
||||
'Exposes experimental screencast tools (requires ffmpeg). Install ffmpeg https://www.ffmpeg.org/download.html and ensure it is available in the MCP server PATH.',
|
||||
},
|
||||
chromeArg: {
|
||||
type: 'array',
|
||||
describe:
|
||||
|
||||
@@ -184,6 +184,12 @@ function registerTool(tool: ToolDefinition): void {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
tool.annotations.conditions?.includes('screencast') &&
|
||||
!args.experimentalScreencast
|
||||
) {
|
||||
return;
|
||||
}
|
||||
server.registerTool(
|
||||
tool.name,
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
Dialog,
|
||||
ElementHandle,
|
||||
Page,
|
||||
ScreenRecorder,
|
||||
Viewport,
|
||||
} from '../third_party/index.js';
|
||||
import type {InsightName, TraceResult} from '../trace-processing/parse.js';
|
||||
@@ -152,6 +153,10 @@ export type Context = Readonly<{
|
||||
* Returns a reqid for a cdpRequestId.
|
||||
*/
|
||||
resolveCdpElementId(cdpBackendNodeId: number): string | undefined;
|
||||
getScreenRecorder(): {recorder: ScreenRecorder; filePath: string} | null;
|
||||
setScreenRecorder(
|
||||
data: {recorder: ScreenRecorder; filePath: string} | null,
|
||||
): void;
|
||||
installExtension(path: string): Promise<string>;
|
||||
uninstallExtension(id: string): Promise<void>;
|
||||
listExtensions(): InstalledExtension[];
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {zod} from '../third_party/index.js';
|
||||
import type {ScreenRecorder} from '../third_party/index.js';
|
||||
|
||||
import {ToolCategory} from './categories.js';
|
||||
import {defineTool} from './ToolDefinition.js';
|
||||
|
||||
async function generateTempFilePath(): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'chrome-devtools-mcp-'));
|
||||
return path.join(dir, `screencast.mp4`);
|
||||
}
|
||||
|
||||
export const startScreencast = defineTool({
|
||||
name: 'screencast_start',
|
||||
description:
|
||||
'Starts recording a screencast (video) of the selected page in mp4 format.',
|
||||
annotations: {
|
||||
category: ToolCategory.DEBUGGING,
|
||||
readOnlyHint: false,
|
||||
conditions: ['screencast'],
|
||||
},
|
||||
schema: {
|
||||
path: zod
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Output path. Uses mkdtemp to generate a unique path if not provided.',
|
||||
),
|
||||
},
|
||||
handler: async (request, response, context) => {
|
||||
if (context.getScreenRecorder() !== null) {
|
||||
response.appendResponseLine(
|
||||
'Error: a screencast recording is already in progress. Use screencast_stop to stop it before starting a new one.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = request.params.path ?? (await generateTempFilePath());
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
|
||||
const page = context.getSelectedPage();
|
||||
|
||||
let recorder: ScreenRecorder;
|
||||
try {
|
||||
recorder = await page.screencast({
|
||||
path: resolvedPath as `${string}.mp4`,
|
||||
format: 'mp4' as const,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes('ENOENT') && message.includes('ffmpeg')) {
|
||||
throw new Error(
|
||||
'ffmpeg is required for screencast recording but was not found. ' +
|
||||
'Install ffmpeg (https://ffmpeg.org/) and ensure it is available in your PATH.',
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
context.setScreenRecorder({recorder, filePath: resolvedPath});
|
||||
|
||||
response.appendResponseLine(
|
||||
`Screencast recording started. The recording will be saved to ${resolvedPath}. Use ${stopScreencast.name} to stop recording.`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const stopScreencast = defineTool({
|
||||
name: 'screencast_stop',
|
||||
description: 'Stops the active screencast recording on the selected page.',
|
||||
annotations: {
|
||||
category: ToolCategory.DEBUGGING,
|
||||
readOnlyHint: false,
|
||||
conditions: ['screencast'],
|
||||
},
|
||||
schema: {},
|
||||
handler: async (_request, response, context) => {
|
||||
const data = context.getScreenRecorder();
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await data.recorder.stop();
|
||||
response.appendResponseLine(
|
||||
`The screencast recording has been stopped and saved to ${data.filePath}.`,
|
||||
);
|
||||
} finally {
|
||||
context.setScreenRecorder(null);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import * as inputTools from './input.js';
|
||||
import * as networkTools from './network.js';
|
||||
import * as pagesTools from './pages.js';
|
||||
import * as performanceTools from './performance.js';
|
||||
import * as screencastTools from './screencast.js';
|
||||
import * as screenshotTools from './screenshot.js';
|
||||
import * as scriptTools from './script.js';
|
||||
import * as snapshotTools from './snapshot.js';
|
||||
@@ -24,6 +25,7 @@ const tools = [
|
||||
...Object.values(networkTools),
|
||||
...Object.values(pagesTools),
|
||||
...Object.values(performanceTools),
|
||||
...Object.values(screencastTools),
|
||||
...Object.values(screenshotTools),
|
||||
...Object.values(scriptTools),
|
||||
...Object.values(snapshotTools),
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import assert from 'node:assert';
|
||||
import {describe, it, afterEach} from 'node:test';
|
||||
|
||||
import sinon from 'sinon';
|
||||
|
||||
import {startScreencast, stopScreencast} from '../../src/tools/screencast.js';
|
||||
import {withMcpContext} from '../utils.js';
|
||||
|
||||
function createMockRecorder() {
|
||||
return {
|
||||
stop: sinon.stub().resolves(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('screencast', () => {
|
||||
afterEach(() => {
|
||||
sinon.restore();
|
||||
});
|
||||
|
||||
describe('screencast_start', () => {
|
||||
it('starts a screencast recording with filePath', async () => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
const mockRecorder = createMockRecorder();
|
||||
const selectedPage = context.getSelectedPage();
|
||||
const screencastStub = sinon
|
||||
.stub(selectedPage, 'screencast')
|
||||
.resolves(mockRecorder as never);
|
||||
|
||||
await startScreencast.handler(
|
||||
{params: {path: '/tmp/test-recording.mp4'}},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
|
||||
sinon.assert.calledOnce(screencastStub);
|
||||
const callArgs = screencastStub.firstCall.args[0];
|
||||
assert.ok(callArgs);
|
||||
assert.ok(callArgs.path?.endsWith('test-recording.mp4'));
|
||||
|
||||
assert.ok(context.getScreenRecorder() !== null);
|
||||
assert.ok(
|
||||
response.responseLines
|
||||
.join('\n')
|
||||
.includes('Screencast recording started'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('starts a screencast recording with temp file when no filePath', async () => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
const mockRecorder = createMockRecorder();
|
||||
const selectedPage = context.getSelectedPage();
|
||||
const screencastStub = sinon
|
||||
.stub(selectedPage, 'screencast')
|
||||
.resolves(mockRecorder as never);
|
||||
|
||||
await startScreencast.handler({params: {}}, response, context);
|
||||
|
||||
sinon.assert.calledOnce(screencastStub);
|
||||
const callArgs = screencastStub.firstCall.args[0];
|
||||
assert.ok(callArgs);
|
||||
assert.ok(callArgs.path?.endsWith('.mp4'));
|
||||
assert.ok(context.getScreenRecorder() !== null);
|
||||
});
|
||||
});
|
||||
|
||||
it('errors if a recording is already active', async () => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
const mockRecorder = createMockRecorder();
|
||||
context.setScreenRecorder({
|
||||
recorder: mockRecorder as never,
|
||||
filePath: '/tmp/existing.mp4',
|
||||
});
|
||||
|
||||
const selectedPage = context.getSelectedPage();
|
||||
const screencastStub = sinon.stub(selectedPage, 'screencast');
|
||||
|
||||
await startScreencast.handler({params: {}}, response, context);
|
||||
|
||||
sinon.assert.notCalled(screencastStub);
|
||||
assert.ok(
|
||||
response.responseLines
|
||||
.join('\n')
|
||||
.includes('a screencast recording is already in progress'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('provides a clear error when ffmpeg is not found', async () => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
const selectedPage = context.getSelectedPage();
|
||||
const error = new Error('spawn ffmpeg ENOENT');
|
||||
sinon.stub(selectedPage, 'screencast').rejects(error);
|
||||
|
||||
await assert.rejects(
|
||||
startScreencast.handler(
|
||||
{params: {path: '/tmp/test.mp4'}},
|
||||
response,
|
||||
context,
|
||||
),
|
||||
/ffmpeg is required for screencast recording/,
|
||||
);
|
||||
|
||||
assert.strictEqual(context.getScreenRecorder(), null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('screencast_stop', () => {
|
||||
it('does nothing if no recording is active', async () => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
assert.strictEqual(context.getScreenRecorder(), null);
|
||||
await stopScreencast.handler({params: {}}, response, context);
|
||||
assert.strictEqual(response.responseLines.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
it('stops an active recording and reports the file path', async () => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
const mockRecorder = createMockRecorder();
|
||||
const filePath = '/tmp/test-recording.mp4';
|
||||
context.setScreenRecorder({
|
||||
recorder: mockRecorder as never,
|
||||
filePath,
|
||||
});
|
||||
|
||||
await stopScreencast.handler({params: {}}, response, context);
|
||||
|
||||
sinon.assert.calledOnce(mockRecorder.stop);
|
||||
assert.strictEqual(context.getScreenRecorder(), null);
|
||||
assert.ok(
|
||||
response.responseLines
|
||||
.join('\n')
|
||||
.includes('stopped and saved to /tmp/test-recording.mp4'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the recorder even if stop() throws', async () => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
const mockRecorder = createMockRecorder();
|
||||
mockRecorder.stop.rejects(new Error('ffmpeg process error'));
|
||||
context.setScreenRecorder({
|
||||
recorder: mockRecorder as never,
|
||||
filePath: '/tmp/test.mp4',
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
stopScreencast.handler({params: {}}, response, context),
|
||||
/ffmpeg process error/,
|
||||
);
|
||||
|
||||
assert.strictEqual(context.getScreenRecorder(), null);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user