Skip to content

Commit 65288a5

Browse files
feat: add screencast_start and screencast_stop tools
Implement screen recording via Puppeteer's page.screencast() API (closes #878). Supports webm/mp4/gif formats with configurable quality, fps, scale, and speed. Provides clear error messaging when ffmpeg is not installed. Includes 8 unit tests covering start/stop lifecycle, option passthrough, duplicate recording guard, ffmpeg error handling, and cleanup on failure. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent af7c8ef commit 65288a5

3 files changed

Lines changed: 348 additions & 0 deletions

File tree

src/tools/screencast.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/**
2+
* @license
3+
* Copyright 2025 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, VideoFormat} from '../third_party/index.js';
13+
14+
import {ToolCategory} from './categories.js';
15+
import type {Context, Response} from './ToolDefinition.js';
16+
import {defineTool} from './ToolDefinition.js';
17+
18+
async function generateTempFilePath(format: VideoFormat): Promise<string> {
19+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'chrome-devtools-mcp-'));
20+
return path.join(dir, `screencast.${format}`);
21+
}
22+
23+
export const startScreencast = defineTool({
24+
name: 'screencast_start',
25+
description:
26+
'Starts recording a screencast (video) of the selected page. Requires ffmpeg to be installed on the system.',
27+
annotations: {
28+
category: ToolCategory.DEBUGGING,
29+
readOnlyHint: false,
30+
},
31+
schema: {
32+
filePath: zod
33+
.string()
34+
.optional()
35+
.describe(
36+
'The absolute file path, or a file path relative to the current working directory, to save the screencast to. For example, recording.webm. If not specified, a temporary file will be created.',
37+
),
38+
format: zod
39+
.enum(['webm', 'mp4', 'gif'])
40+
.default('webm')
41+
.describe('Specifies the output file format. Default is "webm".'),
42+
quality: zod
43+
.number()
44+
.min(0)
45+
.max(63)
46+
.optional()
47+
.describe(
48+
'Recording quality (CRF) between 0-63. Lower values mean better quality but larger files. Default is 30.',
49+
),
50+
fps: zod
51+
.number()
52+
.optional()
53+
.describe('Frame rate in frames per second. Default is 30 (20 for GIF).'),
54+
scale: zod
55+
.number()
56+
.optional()
57+
.describe(
58+
'Scales the output video. For example, 0.5 will halve the dimensions. Default is 1.',
59+
),
60+
speed: zod
61+
.number()
62+
.optional()
63+
.describe(
64+
'Playback speed multiplier. For example, 2 will double the speed. Default is 1.',
65+
),
66+
},
67+
handler: async (request, response, context) => {
68+
if (context.getScreenRecorder() !== null) {
69+
response.appendResponseLine(
70+
'Error: a screencast recording is already in progress. Use screencast_stop to stop it before starting a new one.',
71+
);
72+
return;
73+
}
74+
75+
const format = request.params.format as VideoFormat;
76+
const filePath =
77+
request.params.filePath ?? (await generateTempFilePath(format));
78+
const resolvedPath = path.resolve(filePath);
79+
80+
const page = context.getSelectedPage();
81+
82+
let recorder: ScreenRecorder;
83+
try {
84+
recorder = await page.screencast({
85+
path: resolvedPath as `${string}.${VideoFormat}`,
86+
format,
87+
quality: request.params.quality,
88+
fps: request.params.fps,
89+
scale: request.params.scale,
90+
speed: request.params.speed,
91+
});
92+
} catch (err) {
93+
const message = err instanceof Error ? err.message : String(err);
94+
if (message.includes('ENOENT') && message.includes('ffmpeg')) {
95+
throw new Error(
96+
'ffmpeg is required for screencast recording but was not found. ' +
97+
'Install ffmpeg (https://ffmpeg.org/) and ensure it is available in your PATH.',
98+
);
99+
}
100+
throw err;
101+
}
102+
103+
context.setScreenRecorder({recorder, filePath: resolvedPath});
104+
105+
response.appendResponseLine(
106+
`Screencast recording started. The recording will be saved to ${resolvedPath}. Use screencast_stop to stop recording.`,
107+
);
108+
},
109+
});
110+
111+
export const stopScreencast = defineTool({
112+
name: 'screencast_stop',
113+
description: 'Stops the active screencast recording on the selected page.',
114+
annotations: {
115+
category: ToolCategory.DEBUGGING,
116+
readOnlyHint: false,
117+
},
118+
schema: {},
119+
handler: async (_request, response, context) => {
120+
await stopScreencastAndAppendOutput(response, context);
121+
},
122+
});
123+
124+
async function stopScreencastAndAppendOutput(
125+
response: Response,
126+
context: Context,
127+
): Promise<void> {
128+
const data = context.getScreenRecorder();
129+
if (!data) {
130+
return;
131+
}
132+
try {
133+
await data.recorder.stop();
134+
response.appendResponseLine(
135+
`The screencast recording has been stopped and saved to ${data.filePath}.`,
136+
);
137+
} finally {
138+
context.setScreenRecorder(null);
139+
}
140+
}

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: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
/**
2+
* @license
3+
* Copyright 2025 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: {format: 'webm', filePath: '/tmp/test-recording.webm'}},
37+
response,
38+
context,
39+
);
40+
41+
sinon.assert.calledOnce(screencastStub);
42+
const callArgs = screencastStub.firstCall.args[0];
43+
assert.ok(callArgs);
44+
assert.strictEqual(callArgs.format, 'webm');
45+
assert.ok(callArgs.path?.endsWith('test-recording.webm'));
46+
47+
assert.ok(context.getScreenRecorder() !== null);
48+
assert.ok(
49+
response.responseLines
50+
.join('\n')
51+
.includes('Screencast recording started'),
52+
);
53+
});
54+
});
55+
56+
it('starts a screencast recording with temp file when no filePath', async () => {
57+
await withMcpContext(async (response, context) => {
58+
const mockRecorder = createMockRecorder();
59+
const selectedPage = context.getSelectedPage();
60+
const screencastStub = sinon
61+
.stub(selectedPage, 'screencast')
62+
.resolves(mockRecorder as never);
63+
64+
await startScreencast.handler(
65+
{params: {format: 'webm'}},
66+
response,
67+
context,
68+
);
69+
70+
sinon.assert.calledOnce(screencastStub);
71+
const callArgs = screencastStub.firstCall.args[0];
72+
assert.ok(callArgs);
73+
assert.ok(callArgs.path?.endsWith('.webm'));
74+
assert.ok(context.getScreenRecorder() !== null);
75+
});
76+
});
77+
78+
it('passes format and quality options', async () => {
79+
await withMcpContext(async (response, context) => {
80+
const mockRecorder = createMockRecorder();
81+
const selectedPage = context.getSelectedPage();
82+
const screencastStub = sinon
83+
.stub(selectedPage, 'screencast')
84+
.resolves(mockRecorder as never);
85+
86+
await startScreencast.handler(
87+
{
88+
params: {
89+
format: 'mp4',
90+
filePath: '/tmp/test.mp4',
91+
quality: 20,
92+
fps: 24,
93+
scale: 0.5,
94+
speed: 2,
95+
},
96+
},
97+
response,
98+
context,
99+
);
100+
101+
sinon.assert.calledOnce(screencastStub);
102+
const callArgs = screencastStub.firstCall.args[0];
103+
assert.ok(callArgs);
104+
assert.strictEqual(callArgs.format, 'mp4');
105+
assert.strictEqual(callArgs.quality, 20);
106+
assert.strictEqual(callArgs.fps, 24);
107+
assert.strictEqual(callArgs.scale, 0.5);
108+
assert.strictEqual(callArgs.speed, 2);
109+
});
110+
});
111+
112+
it('errors if a recording is already active', async () => {
113+
await withMcpContext(async (response, context) => {
114+
const mockRecorder = createMockRecorder();
115+
context.setScreenRecorder({
116+
recorder: mockRecorder as never,
117+
filePath: '/tmp/existing.webm',
118+
});
119+
120+
const selectedPage = context.getSelectedPage();
121+
const screencastStub = sinon.stub(selectedPage, 'screencast');
122+
123+
await startScreencast.handler(
124+
{params: {format: 'webm'}},
125+
response,
126+
context,
127+
);
128+
129+
sinon.assert.notCalled(screencastStub);
130+
assert.ok(
131+
response.responseLines
132+
.join('\n')
133+
.includes('a screencast recording is already in progress'),
134+
);
135+
});
136+
});
137+
138+
it('provides a clear error when ffmpeg is not found', async () => {
139+
await withMcpContext(async (response, context) => {
140+
const selectedPage = context.getSelectedPage();
141+
const error = new Error('spawn ffmpeg ENOENT');
142+
sinon.stub(selectedPage, 'screencast').rejects(error);
143+
144+
await assert.rejects(
145+
startScreencast.handler(
146+
{params: {format: 'webm', filePath: '/tmp/test.webm'}},
147+
response,
148+
context,
149+
),
150+
/ffmpeg is required for screencast recording/,
151+
);
152+
153+
assert.strictEqual(context.getScreenRecorder(), null);
154+
});
155+
});
156+
});
157+
158+
describe('screencast_stop', () => {
159+
it('does nothing if no recording is active', async () => {
160+
await withMcpContext(async (response, context) => {
161+
assert.strictEqual(context.getScreenRecorder(), null);
162+
await stopScreencast.handler({params: {}}, response, context);
163+
assert.strictEqual(response.responseLines.length, 0);
164+
});
165+
});
166+
167+
it('stops an active recording and reports the file path', async () => {
168+
await withMcpContext(async (response, context) => {
169+
const mockRecorder = createMockRecorder();
170+
const filePath = '/tmp/test-recording.webm';
171+
context.setScreenRecorder({
172+
recorder: mockRecorder as never,
173+
filePath,
174+
});
175+
176+
await stopScreencast.handler({params: {}}, response, context);
177+
178+
sinon.assert.calledOnce(mockRecorder.stop);
179+
assert.strictEqual(context.getScreenRecorder(), null);
180+
assert.ok(
181+
response.responseLines
182+
.join('\n')
183+
.includes('stopped and saved to /tmp/test-recording.webm'),
184+
);
185+
});
186+
});
187+
188+
it('clears the recorder even if stop() throws', async () => {
189+
await withMcpContext(async (response, context) => {
190+
const mockRecorder = createMockRecorder();
191+
mockRecorder.stop.rejects(new Error('ffmpeg process error'));
192+
context.setScreenRecorder({
193+
recorder: mockRecorder as never,
194+
filePath: '/tmp/test.webm',
195+
});
196+
197+
await assert.rejects(
198+
stopScreencast.handler({params: {}}, response, context),
199+
/ffmpeg process error/,
200+
);
201+
202+
assert.strictEqual(context.getScreenRecorder(), null);
203+
});
204+
});
205+
});
206+
});

0 commit comments

Comments
 (0)