豆豆友情提示:这是一个非官方 GitHub 代理镜像,主要用于网络测试或访问加速。请勿在此进行登录、注册或处理任何敏感信息。进行这些操作请务必访问官方网站 github.com。 Raw 内容也通过此代理提供。
Skip to content

Commit 33446d4

Browse files
dobesvOrKoN
andauthored
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>
1 parent 5696cce commit 33446d4

File tree

8 files changed

+298
-0
lines changed

8 files changed

+298
-0
lines changed

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,10 @@ The Chrome DevTools MCP server supports the following configuration option:
495495
If enabled, ignores errors relative to self-signed and expired certificates. Use with caution.
496496
- **Type:** boolean
497497

498+
- **`--experimentalScreencast`/ `--experimental-screencast`**
499+
Exposes experimental screencast tools (requires ffmpeg). Install ffmpeg https://www.ffmpeg.org/download.html and ensure it is available in the MCP server PATH.
500+
- **Type:** boolean
501+
498502
- **`--chromeArg`/ `--chrome-arg`**
499503
Additional arguments for Chrome. Only applies when Chrome is launched by chrome-devtools-mcp.
500504
- **Type:** array

src/McpContext.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import type {
2525
ElementHandle,
2626
HTTPRequest,
2727
Page,
28+
ScreenRecorder,
2829
SerializedAXNode,
2930
Viewport,
3031
} from './third_party/index.js';
@@ -130,6 +131,8 @@ export class McpContext implements Context {
130131
#extensionRegistry = new ExtensionRegistry();
131132

132133
#isRunningTrace = false;
134+
#screenRecorderData: {recorder: ScreenRecorder; filePath: string} | null =
135+
null;
133136
#emulationSettingsMap = new WeakMap<Page, EmulationSettings>();
134137
#dialog?: Dialog;
135138

@@ -436,6 +439,16 @@ export class McpContext implements Context {
436439
return this.#isRunningTrace;
437440
}
438441

442+
getScreenRecorder(): {recorder: ScreenRecorder; filePath: string} | null {
443+
return this.#screenRecorderData;
444+
}
445+
446+
setScreenRecorder(
447+
data: {recorder: ScreenRecorder; filePath: string} | null,
448+
): void {
449+
this.#screenRecorderData = data;
450+
}
451+
439452
isCruxEnabled(): boolean {
440453
return this.#options.performanceCrux;
441454
}

src/cli.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,11 @@ export const cliOptions = {
173173
describe: 'Whether to enable interoperability tools',
174174
hidden: true,
175175
},
176+
experimentalScreencast: {
177+
type: 'boolean',
178+
describe:
179+
'Exposes experimental screencast tools (requires ffmpeg). Install ffmpeg https://www.ffmpeg.org/download.html and ensure it is available in the MCP server PATH.',
180+
},
176181
chromeArg: {
177182
type: 'array',
178183
describe:

src/main.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,12 @@ function registerTool(tool: ToolDefinition): void {
184184
) {
185185
return;
186186
}
187+
if (
188+
tool.annotations.conditions?.includes('screencast') &&
189+
!args.experimentalScreencast
190+
) {
191+
return;
192+
}
187193
server.registerTool(
188194
tool.name,
189195
{

src/tools/ToolDefinition.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
Dialog,
1111
ElementHandle,
1212
Page,
13+
ScreenRecorder,
1314
Viewport,
1415
} from '../third_party/index.js';
1516
import type {InsightName, TraceResult} from '../trace-processing/parse.js';
@@ -152,6 +153,10 @@ export type Context = Readonly<{
152153
* Returns a reqid for a cdpRequestId.
153154
*/
154155
resolveCdpElementId(cdpBackendNodeId: number): string | undefined;
156+
getScreenRecorder(): {recorder: ScreenRecorder; filePath: string} | null;
157+
setScreenRecorder(
158+
data: {recorder: ScreenRecorder; filePath: string} | null,
159+
): void;
155160
installExtension(path: string): Promise<string>;
156161
uninstallExtension(id: string): Promise<void>;
157162
listExtensions(): InstalledExtension[];

src/tools/screencast.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import fs from 'node:fs/promises';
8+
import os from 'node:os';
9+
import path from 'node:path';
10+
11+
import {zod} from '../third_party/index.js';
12+
import type {ScreenRecorder} from '../third_party/index.js';
13+
14+
import {ToolCategory} from './categories.js';
15+
import {defineTool} from './ToolDefinition.js';
16+
17+
async function generateTempFilePath(): Promise<string> {
18+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'chrome-devtools-mcp-'));
19+
return path.join(dir, `screencast.mp4`);
20+
}
21+
22+
export const startScreencast = defineTool({
23+
name: 'screencast_start',
24+
description:
25+
'Starts recording a screencast (video) of the selected page in mp4 format.',
26+
annotations: {
27+
category: ToolCategory.DEBUGGING,
28+
readOnlyHint: false,
29+
conditions: ['screencast'],
30+
},
31+
schema: {
32+
path: zod
33+
.string()
34+
.optional()
35+
.describe(
36+
'Output path. Uses mkdtemp to generate a unique path if not provided.',
37+
),
38+
},
39+
handler: async (request, response, context) => {
40+
if (context.getScreenRecorder() !== null) {
41+
response.appendResponseLine(
42+
'Error: a screencast recording is already in progress. Use screencast_stop to stop it before starting a new one.',
43+
);
44+
return;
45+
}
46+
47+
const filePath = request.params.path ?? (await generateTempFilePath());
48+
const resolvedPath = path.resolve(filePath);
49+
50+
const page = context.getSelectedPage();
51+
52+
let recorder: ScreenRecorder;
53+
try {
54+
recorder = await page.screencast({
55+
path: resolvedPath as `${string}.mp4`,
56+
format: 'mp4' as const,
57+
});
58+
} catch (err) {
59+
const message = err instanceof Error ? err.message : String(err);
60+
if (message.includes('ENOENT') && message.includes('ffmpeg')) {
61+
throw new Error(
62+
'ffmpeg is required for screencast recording but was not found. ' +
63+
'Install ffmpeg (https://ffmpeg.org/) and ensure it is available in your PATH.',
64+
);
65+
}
66+
throw err;
67+
}
68+
69+
context.setScreenRecorder({recorder, filePath: resolvedPath});
70+
71+
response.appendResponseLine(
72+
`Screencast recording started. The recording will be saved to ${resolvedPath}. Use ${stopScreencast.name} to stop recording.`,
73+
);
74+
},
75+
});
76+
77+
export const stopScreencast = defineTool({
78+
name: 'screencast_stop',
79+
description: 'Stops the active screencast recording on the selected page.',
80+
annotations: {
81+
category: ToolCategory.DEBUGGING,
82+
readOnlyHint: false,
83+
conditions: ['screencast'],
84+
},
85+
schema: {},
86+
handler: async (_request, response, context) => {
87+
const data = context.getScreenRecorder();
88+
if (!data) {
89+
return;
90+
}
91+
try {
92+
await data.recorder.stop();
93+
response.appendResponseLine(
94+
`The screencast recording has been stopped and saved to ${data.filePath}.`,
95+
);
96+
} finally {
97+
context.setScreenRecorder(null);
98+
}
99+
},
100+
});

src/tools/tools.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import * as inputTools from './input.js';
1111
import * as networkTools from './network.js';
1212
import * as pagesTools from './pages.js';
1313
import * as performanceTools from './performance.js';
14+
import * as screencastTools from './screencast.js';
1415
import * as screenshotTools from './screenshot.js';
1516
import * as scriptTools from './script.js';
1617
import * as snapshotTools from './snapshot.js';
@@ -24,6 +25,7 @@ const tools = [
2425
...Object.values(networkTools),
2526
...Object.values(pagesTools),
2627
...Object.values(performanceTools),
28+
...Object.values(screencastTools),
2729
...Object.values(screenshotTools),
2830
...Object.values(scriptTools),
2931
...Object.values(snapshotTools),

tests/tools/screencast.test.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import assert from 'node:assert';
8+
import {describe, it, afterEach} from 'node:test';
9+
10+
import sinon from 'sinon';
11+
12+
import {startScreencast, stopScreencast} from '../../src/tools/screencast.js';
13+
import {withMcpContext} from '../utils.js';
14+
15+
function createMockRecorder() {
16+
return {
17+
stop: sinon.stub().resolves(),
18+
};
19+
}
20+
21+
describe('screencast', () => {
22+
afterEach(() => {
23+
sinon.restore();
24+
});
25+
26+
describe('screencast_start', () => {
27+
it('starts a screencast recording with filePath', async () => {
28+
await withMcpContext(async (response, context) => {
29+
const mockRecorder = createMockRecorder();
30+
const selectedPage = context.getSelectedPage();
31+
const screencastStub = sinon
32+
.stub(selectedPage, 'screencast')
33+
.resolves(mockRecorder as never);
34+
35+
await startScreencast.handler(
36+
{params: {path: '/tmp/test-recording.mp4'}},
37+
response,
38+
context,
39+
);
40+
41+
sinon.assert.calledOnce(screencastStub);
42+
const callArgs = screencastStub.firstCall.args[0];
43+
assert.ok(callArgs);
44+
assert.ok(callArgs.path?.endsWith('test-recording.mp4'));
45+
46+
assert.ok(context.getScreenRecorder() !== null);
47+
assert.ok(
48+
response.responseLines
49+
.join('\n')
50+
.includes('Screencast recording started'),
51+
);
52+
});
53+
});
54+
55+
it('starts a screencast recording with temp file when no filePath', async () => {
56+
await withMcpContext(async (response, context) => {
57+
const mockRecorder = createMockRecorder();
58+
const selectedPage = context.getSelectedPage();
59+
const screencastStub = sinon
60+
.stub(selectedPage, 'screencast')
61+
.resolves(mockRecorder as never);
62+
63+
await startScreencast.handler({params: {}}, response, context);
64+
65+
sinon.assert.calledOnce(screencastStub);
66+
const callArgs = screencastStub.firstCall.args[0];
67+
assert.ok(callArgs);
68+
assert.ok(callArgs.path?.endsWith('.mp4'));
69+
assert.ok(context.getScreenRecorder() !== null);
70+
});
71+
});
72+
73+
it('errors if a recording is already active', async () => {
74+
await withMcpContext(async (response, context) => {
75+
const mockRecorder = createMockRecorder();
76+
context.setScreenRecorder({
77+
recorder: mockRecorder as never,
78+
filePath: '/tmp/existing.mp4',
79+
});
80+
81+
const selectedPage = context.getSelectedPage();
82+
const screencastStub = sinon.stub(selectedPage, 'screencast');
83+
84+
await startScreencast.handler({params: {}}, response, context);
85+
86+
sinon.assert.notCalled(screencastStub);
87+
assert.ok(
88+
response.responseLines
89+
.join('\n')
90+
.includes('a screencast recording is already in progress'),
91+
);
92+
});
93+
});
94+
95+
it('provides a clear error when ffmpeg is not found', async () => {
96+
await withMcpContext(async (response, context) => {
97+
const selectedPage = context.getSelectedPage();
98+
const error = new Error('spawn ffmpeg ENOENT');
99+
sinon.stub(selectedPage, 'screencast').rejects(error);
100+
101+
await assert.rejects(
102+
startScreencast.handler(
103+
{params: {path: '/tmp/test.mp4'}},
104+
response,
105+
context,
106+
),
107+
/ffmpeg is required for screencast recording/,
108+
);
109+
110+
assert.strictEqual(context.getScreenRecorder(), null);
111+
});
112+
});
113+
});
114+
115+
describe('screencast_stop', () => {
116+
it('does nothing if no recording is active', async () => {
117+
await withMcpContext(async (response, context) => {
118+
assert.strictEqual(context.getScreenRecorder(), null);
119+
await stopScreencast.handler({params: {}}, response, context);
120+
assert.strictEqual(response.responseLines.length, 0);
121+
});
122+
});
123+
124+
it('stops an active recording and reports the file path', async () => {
125+
await withMcpContext(async (response, context) => {
126+
const mockRecorder = createMockRecorder();
127+
const filePath = '/tmp/test-recording.mp4';
128+
context.setScreenRecorder({
129+
recorder: mockRecorder as never,
130+
filePath,
131+
});
132+
133+
await stopScreencast.handler({params: {}}, response, context);
134+
135+
sinon.assert.calledOnce(mockRecorder.stop);
136+
assert.strictEqual(context.getScreenRecorder(), null);
137+
assert.ok(
138+
response.responseLines
139+
.join('\n')
140+
.includes('stopped and saved to /tmp/test-recording.mp4'),
141+
);
142+
});
143+
});
144+
145+
it('clears the recorder even if stop() throws', async () => {
146+
await withMcpContext(async (response, context) => {
147+
const mockRecorder = createMockRecorder();
148+
mockRecorder.stop.rejects(new Error('ffmpeg process error'));
149+
context.setScreenRecorder({
150+
recorder: mockRecorder as never,
151+
filePath: '/tmp/test.mp4',
152+
});
153+
154+
await assert.rejects(
155+
stopScreencast.handler({params: {}}, response, context),
156+
/ffmpeg process error/,
157+
);
158+
159+
assert.strictEqual(context.getScreenRecorder(), null);
160+
});
161+
});
162+
});
163+
});

0 commit comments

Comments
 (0)