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:
Dobes VandermeerandAlex Rudenko authored and GitHub committed 2026-02-20 13:15:48 +00:00
1 parent 5696cce035
commit 33446d457e
8 files changed
+298

No files matched your search

+4
View File
@@ -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
+13
View File
@@ -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;
}
+5
View File
@@ -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:
+6
View File
@@ -184,6 +184,12 @@ function registerTool(tool: ToolDefinition): void {
) {
return;
}
if (
tool.annotations.conditions?.includes('screencast') &&
!args.experimentalScreencast
) {
return;
}
server.registerTool(
tool.name,
{
+5
View File
@@ -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[];
+100
View File
@@ -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);
}
},
});
+2
View File
@@ -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),
+163
View File
@@ -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);
});
});
});
});