#!/usr/bin/env node
/**
* Viewpo MCP Server
*
* Gives AI coding assistants visual inspection tools for responsive
* web development via the Viewpo macOS app.
*
* Transport: stdio (for Claude Code, Cursor, Windsurf, etc.)
* Communicates with: Viewpo macOS app on localhost:9847
*
* @see https://github.com/littlebearapps/viewpo-mcp
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { ViewpoBridgeClient } from "./client.js";
// --- Configuration from environment ---
const port = parseInt(process.env.VIEWPO_PORT || "9847", 10);
const authToken = process.env.VIEWPO_AUTH_TOKEN || "";
if (!authToken) {
console.error(
"Warning: VIEWPO_AUTH_TOKEN not set. Copy the token from Viewpo > Settings > MCP Bridge."
);
}
const client = new ViewpoBridgeClient(port, authToken);
// --- MCP Server ---
const server = new McpServer({
name: "viewpo",
version: "0.1.0",
});
// --- Tool: viewpo_screenshot ---
server.tool(
"viewpo_screenshot",
"Capture screenshots of a URL at one or more viewport widths. Returns base64 JPEG images. Use this to SEE what a webpage looks like at different screen sizes.",
{
url: z.string().url().describe("The URL to screenshot"),
viewports: z
.array(
z.object({
width: z
.number()
.int()
.min(100)
.max(3840)
.describe("Viewport width in CSS pixels"),
name: z
.string()
.optional()
.describe('Label for this viewport (e.g. "phone", "desktop")'),
})
)
.optional()
.describe(
"Viewports to capture. Defaults to desktop (1920px) if omitted. Common widths: 375 (phone), 820 (tablet), 1920 (desktop)."
),
},
async ({ url, viewports }) => {
try {
const result = await client.screenshot({ url, viewports });
const content = result.viewports.flatMap((vp) => [
{
type: "text" as const,
text: `**${vp.viewport}** (${vp.width}\u00d7${vp.height})`,
},
{
type: "image" as const,
data: vp.image_base64,
mimeType: "image/jpeg" as const,
},
]);
return { content };
} catch (error) {
return {
content: [
{
type: "text" as const,
text: `Screenshot failed: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
}
}
);
// --- Tool: viewpo_get_layout_map ---
server.tool(
"viewpo_get_layout_map",
"Extract the DOM layout tree of a URL at a given viewport width. Returns element hierarchy with tags, classes, bounding rects, and computed CSS styles. Use this to understand page structure and find layout issues.",
{
url: z.string().url().describe("The URL to inspect"),
viewport: z
.number()
.int()
.min(100)
.max(3840)
.optional()
.describe("Viewport width in CSS pixels (default: 1920)"),
selector: z
.string()
.optional()
.describe(
'CSS selector to scope the layout map to a subtree (e.g. ".main-content", "#hero")'
),
},
async ({ url, viewport, selector }) => {
try {
const result = await client.layoutMap({ url, viewport, selector });
return {
content: [
{
type: "text" as const,
text: JSON.stringify(result, null, 2),
},
],
};
} catch (error) {
return {
content: [
{
type: "text" as const,
text: `Layout map failed: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
}
}
);
// --- Tool: viewpo_compare_viewports ---
server.tool(
"viewpo_compare_viewports",
"Compare the layout of a URL at two different viewport widths. Returns a list of elements whose size or CSS styles differ between the two viewports. Use this to find responsive design issues.",
{
url: z.string().url().describe("The URL to compare"),
viewport_a: z
.number()
.int()
.min(100)
.max(3840)
.describe("First viewport width in CSS pixels (e.g. 375 for phone)"),
viewport_b: z
.number()
.int()
.min(100)
.max(3840)
.describe("Second viewport width in CSS pixels (e.g. 1920 for desktop)"),
selector: z
.string()
.optional()
.describe("CSS selector to scope comparison to a subtree"),
},
async ({ url, viewport_a, viewport_b, selector }) => {
try {
const result = await client.compare({
url,
viewport_a,
viewport_b,
selector,
});
const summary =
result.differences.length === 0
? "No layout differences found between the two viewports."
: `Found ${result.differences.length} difference(s) between ${result.viewport_a}px and ${result.viewport_b}px:`;
return {
content: [
{
type: "text" as const,
text: `${summary}\n\n${JSON.stringify(result, null, 2)}`,
},
],
};
} catch (error) {
return {
content: [
{
type: "text" as const,
text: `Compare failed: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
}
}
);
// --- Start ---
async function main(): Promise<void> {
const transport = new StdioServerTransport();
await server.connect(transport);
}
main().catch((error) => {
console.error("Viewpo MCP server fatal error:", error);
process.exit(1);
});