Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.7.0] - 2026-02-13

### Added
- **`saveTo` parameter for screenshot tools**: New optional parameter on `screenshot_page` and `screenshot_by_uid` that saves the screenshot to a local file instead of returning base64 image data in the MCP response
- Solves context window bloat in CLI-based MCP clients (e.g. Claude Code) where large base64 screenshots fill up the context quickly
- When `saveTo` is provided, returns a lightweight text response with the file path and size
- Automatically creates parent directories if they don't exist
- Follows the same pattern as Chrome DevTools MCP's `filePath` parameter

### Changed
- **Screenshot response format**: Without `saveTo`, screenshots are now returned as native MCP `image` content (`{ type: "image" }`) instead of raw base64 text. GUI clients (Claude Desktop, Cursor) render these natively.
- Removed `buildScreenshotResponse` with its token-limit truncation — no longer needed since screenshots are either saved to file or returned as proper image content
- Extended `McpToolResponse` type to support both `text` and `image` content items

## [0.6.1] - 2026-02-04

### Added
Expand Down Expand Up @@ -165,6 +179,8 @@ Released on npm, see GitHub releases for details.
- UID-based element referencing system
- Headless mode support

[0.7.0]: https://github.com/freema/firefox-devtools-mcp/compare/v0.6.1...v0.7.0
[0.6.1]: https://github.com/freema/firefox-devtools-mcp/compare/v0.6.0...v0.6.1
[0.5.3]: https://github.com/freema/firefox-devtools-mcp/compare/v0.5.2...v0.5.3
[0.5.2]: https://github.com/freema/firefox-devtools-mcp/compare/v0.5.1...v0.5.2
[0.5.1]: https://github.com/freema/firefox-devtools-mcp/compare/v0.5.0...v0.5.1
Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,21 @@ You can pass flags or environment variables (names on the right):
- Input: click/hover/fill/drag/upload/form fill
- Network: list/get (ID‑first, filters, always‑on capture)
- Console: list/clear
- Screenshot: page/by uid
- Screenshot: page/by uid (with optional `saveTo` for CLI environments)
- Utilities: accept/dismiss dialog, history back/forward, set viewport

### Screenshot optimization for Claude Code

When using screenshots in Claude Code CLI, the base64 image data can consume significant context.
Use the `saveTo` parameter to save screenshots to disk instead:

```
screenshot_page({ saveTo: "/tmp/page.png" })
screenshot_by_uid({ uid: "abc123", saveTo: "/tmp/element.png" })
```

The file can then be viewed with Claude Code's `Read` tool without impacting context size.

## Local development

```bash
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "firefox-devtools-mcp",
"version": "0.6.1",
"version": "0.7.0",
"description": "Model Context Protocol (MCP) server for Firefox DevTools automation",
"author": "freema",
"license": "MIT",
Expand Down
2 changes: 1 addition & 1 deletion src/config/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
*/

export const SERVER_NAME = 'firefox-devtools';
export const SERVER_VERSION = '0.2.5';
export const SERVER_VERSION = '0.7.0';
6 changes: 2 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { parseArguments } from './cli.js';
import { FirefoxDevTools } from './firefox/index.js';
import type { FirefoxLaunchOptions } from './firefox/types.js';
import * as tools from './tools/index.js';
import type { McpToolResponse } from './types/common.js';
import { FirefoxDisconnectedError } from './utils/errors.js';

// Export for direct usage in scripts
Expand Down Expand Up @@ -101,10 +102,7 @@ export async function getFirefox(): Promise<FirefoxDevTools> {
}

// Tool handler mapping
const toolHandlers = new Map<
string,
(input: unknown) => Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }>
>([
const toolHandlers = new Map<string, (input: unknown) => Promise<McpToolResponse>>([
// Pages
['list_pages', tools.handleListPages],
['new_page', tools.handleNewPage],
Expand Down
69 changes: 51 additions & 18 deletions src/tools/screenshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,27 @@
* Screenshot tools for visual capture
*/

import { successResponse, errorResponse, TOKEN_LIMITS } from '../utils/response-helpers.js';
import { writeFile, mkdir } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';
import { successResponse, errorResponse } from '../utils/response-helpers.js';
import { handleUidError } from '../utils/uid-helpers.js';
import type { McpToolResponse } from '../types/common.js';

const SAVE_TO_SCHEMA = {
type: 'string',
description:
"Optional absolute file path to save the screenshot to instead of returning it as image data in the response. Use this in CLI environments (e.g. Claude Code) to avoid filling up the context window with large base64 image data. Example: '/tmp/screenshot.png'",
} as const;

// Tool definitions
export const screenshotPageTool = {
name: 'screenshot_page',
description: 'Capture page screenshot as base64 PNG.',
inputSchema: {
type: 'object',
properties: {},
properties: {
saveTo: SAVE_TO_SCHEMA,
},
},
};

Expand All @@ -26,31 +36,46 @@ export const screenshotByUidTool = {
type: 'string',
description: 'Element UID from snapshot',
},
saveTo: SAVE_TO_SCHEMA,
},
required: ['uid'],
},
};

/**
* Build screenshot response with size safeguards.
* Save screenshot to file and return text response with path.
*/
function buildScreenshotResponse(base64Png: string, label: string): McpToolResponse {
const sizeKB = Math.round(base64Png.length / 1024);

// Check if screenshot exceeds size limit
if (base64Png.length > TOKEN_LIMITS.MAX_SCREENSHOT_CHARS) {
const truncatedData = base64Png.slice(0, TOKEN_LIMITS.MAX_SCREENSHOT_CHARS);
return successResponse(`📸 ${label} (${sizeKB}KB) [truncated]\n${truncatedData}`);
}
async function saveScreenshot(base64Png: string, saveTo: string): Promise<McpToolResponse> {
const buffer = Buffer.from(base64Png, 'base64');
const resolvedPath = resolve(saveTo);
await mkdir(dirname(resolvedPath), { recursive: true });
await writeFile(resolvedPath, buffer);

return successResponse(
`Screenshot saved to: ${resolvedPath} (${(buffer.length / 1024).toFixed(1)}KB)`
);
}

// Add warning for large screenshots
const warn = base64Png.length > TOKEN_LIMITS.WARNING_THRESHOLD_CHARS ? ' [large]' : '';
return successResponse(`📸 ${label} (${sizeKB}KB)${warn}\n${base64Png}`);
/**
* Return screenshot as native image content for GUI MCP clients.
*/
function imageResponse(base64Png: string): McpToolResponse {
return {
content: [
{
type: 'image',
data: base64Png,
mimeType: 'image/png',
},
],
};
}

// Handlers
export async function handleScreenshotPage(_args: unknown): Promise<McpToolResponse> {
export async function handleScreenshotPage(args: unknown): Promise<McpToolResponse> {
try {
const { saveTo } = (args ?? {}) as { saveTo?: string };

const { getFirefox } = await import('../index.js');
const firefox = await getFirefox();

Expand All @@ -60,15 +85,19 @@ export async function handleScreenshotPage(_args: unknown): Promise<McpToolRespo
throw new Error('Invalid screenshot data');
}

return buildScreenshotResponse(base64Png, 'page');
if (saveTo) {
return await saveScreenshot(base64Png, saveTo);
}

return imageResponse(base64Png);
} catch (error) {
return errorResponse(error as Error);
}
}

export async function handleScreenshotByUid(args: unknown): Promise<McpToolResponse> {
try {
const { uid } = args as { uid: string };
const { uid, saveTo } = args as { uid: string; saveTo?: string };

if (!uid || typeof uid !== 'string') {
throw new Error('uid required');
Expand All @@ -84,7 +113,11 @@ export async function handleScreenshotByUid(args: unknown): Promise<McpToolRespo
throw new Error('Invalid screenshot data');
}

return buildScreenshotResponse(base64Png, uid);
if (saveTo) {
return await saveScreenshot(base64Png, saveTo);
}

return imageResponse(base64Png);
} catch (error) {
throw handleUidError(error as Error, uid);
}
Expand Down
10 changes: 6 additions & 4 deletions src/types/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
* Common types shared across the Firefox DevTools MCP server
*/

export type McpContentItem =
| { type: 'text'; text: string; [key: string]: unknown }
| { type: 'image'; data: string; mimeType: string; [key: string]: unknown };

export interface McpToolResponse {
content: Array<{
type: string;
text: string;
}>;
[key: string]: unknown;
content: McpContentItem[];
isError?: boolean;
}
2 changes: 1 addition & 1 deletion tests/config/constants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ describe('Constants', () => {
});

it('should match package.json version', () => {
expect(SERVER_VERSION).toBe('0.2.5');
expect(SERVER_VERSION).toBe('0.7.0');
});

it('should be a non-empty string', () => {
Expand Down
2 changes: 1 addition & 1 deletion tests/smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ describe('Smoke Tests', () => {

it('should have valid server version', () => {
expect(SERVER_VERSION).toMatch(/^\d+\.\d+\.\d+/);
expect(SERVER_VERSION).toBe('0.2.5');
expect(SERVER_VERSION).toBe('0.7.0');
});
});

Expand Down
110 changes: 107 additions & 3 deletions tests/tools/screenshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@
* Unit tests for screenshot tools
*/

import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { screenshotPageTool, screenshotByUidTool } from '../../src/tools/screenshot.js';
import { existsSync, readFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';

describe('Screenshot Tools', () => {
describe('Tool Definitions', () => {
Expand All @@ -25,16 +28,117 @@ describe('Screenshot Tools', () => {
});

describe('Schema Properties', () => {
it('screenshotPageTool should have empty properties', () => {
it('screenshotPageTool should have saveTo property', () => {
const { properties } = screenshotPageTool.inputSchema;
expect(properties).toBeDefined();
expect(properties?.saveTo).toBeDefined();
expect(properties?.saveTo?.type).toBe('string');
});

it('screenshotByUidTool should require uid', () => {
it('screenshotByUidTool should require uid and have optional saveTo', () => {
const { properties, required } = screenshotByUidTool.inputSchema;
expect(properties).toBeDefined();
expect(properties?.uid).toBeDefined();
expect(properties?.saveTo).toBeDefined();
expect(properties?.saveTo?.type).toBe('string');
expect(required).toContain('uid');
expect(required).not.toContain('saveTo');
});
});

describe('Handler: saveTo behavior', () => {
const FAKE_BASE64 = Buffer.from('fake-png-data').toString('base64');
let tempDir: string;

beforeEach(() => {
tempDir = join(tmpdir(), `screenshot-test-${Date.now()}`);

vi.doMock('../../src/index.js', () => ({
getFirefox: vi.fn().mockResolvedValue({
takeScreenshotPage: vi.fn().mockResolvedValue(FAKE_BASE64),
takeScreenshotByUid: vi.fn().mockResolvedValue(FAKE_BASE64),
}),
}));
});

afterEach(() => {
vi.restoreAllMocks();
if (existsSync(tempDir)) {
rmSync(tempDir, { recursive: true, force: true });
}
});

it('should save screenshot to file when saveTo is provided (page)', async () => {
const { handleScreenshotPage } = await import('../../src/tools/screenshot.js');
const filePath = join(tempDir, 'page.png');
const result = await handleScreenshotPage({ saveTo: filePath });

expect(result.isError).toBeUndefined();
expect(result.content).toHaveLength(1);
expect(result.content[0]).toHaveProperty('type', 'text');
expect((result.content[0] as { type: 'text'; text: string }).text).toContain(
'Screenshot saved to:'
);
expect((result.content[0] as { type: 'text'; text: string }).text).toContain('KB)');
expect(existsSync(filePath)).toBe(true);

const written = readFileSync(filePath);
expect(written).toEqual(Buffer.from(FAKE_BASE64, 'base64'));
});

it('should save screenshot to file when saveTo is provided (by uid)', async () => {
const { handleScreenshotByUid } = await import('../../src/tools/screenshot.js');
const filePath = join(tempDir, 'element.png');
const result = await handleScreenshotByUid({ uid: 'test-uid', saveTo: filePath });

expect(result.isError).toBeUndefined();
expect(result.content).toHaveLength(1);
expect(result.content[0]).toHaveProperty('type', 'text');
expect((result.content[0] as { type: 'text'; text: string }).text).toContain(
'Screenshot saved to:'
);
expect(existsSync(filePath)).toBe(true);
});

it('should create parent directories when they do not exist', async () => {
const { handleScreenshotPage } = await import('../../src/tools/screenshot.js');
const filePath = join(tempDir, 'nested', 'deep', 'screenshot.png');
const result = await handleScreenshotPage({ saveTo: filePath });

expect(result.isError).toBeUndefined();
expect(existsSync(filePath)).toBe(true);
});

it('should return image content when saveTo is not provided (page)', async () => {
const { handleScreenshotPage } = await import('../../src/tools/screenshot.js');
const result = await handleScreenshotPage({});

expect(result.isError).toBeUndefined();
expect(result.content).toHaveLength(1);
expect(result.content[0]).toHaveProperty('type', 'image');
expect(result.content[0]).toHaveProperty('data', FAKE_BASE64);
expect(result.content[0]).toHaveProperty('mimeType', 'image/png');
});

it('should return image content when saveTo is not provided (by uid)', async () => {
const { handleScreenshotByUid } = await import('../../src/tools/screenshot.js');
const result = await handleScreenshotByUid({ uid: 'test-uid' });

expect(result.isError).toBeUndefined();
expect(result.content).toHaveLength(1);
expect(result.content[0]).toHaveProperty('type', 'image');
expect(result.content[0]).toHaveProperty('data', FAKE_BASE64);
expect(result.content[0]).toHaveProperty('mimeType', 'image/png');
});

it('should resolve relative saveTo path', async () => {
const { handleScreenshotPage } = await import('../../src/tools/screenshot.js');
const relativePath = join(tempDir, 'relative.png');
const result = await handleScreenshotPage({ saveTo: relativePath });

expect(result.isError).toBeUndefined();
expect((result.content[0] as { type: 'text'; text: string }).text).toContain(relativePath);
expect(existsSync(relativePath)).toBe(true);
});
});
});