#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
Tool,
} from '@modelcontextprotocol/sdk/types.js';
import { compareComponentFiles, formatComparisonResult } from './tools/compare.js';
import { analyzeComponent, formatAnalysisResult } from './tools/analyze.js';
import { visualDiffComponents, formatVisualDiffResult, cleanup } from './tools/visual-diff.js';
import { getFigmaSpecs, validateDesignImplementation, formatFigmaSpecsResult } from './tools/figma.js';
import { reviewBranch, formatBranchReviewResult } from './tools/branch-review.js';
import { validateAndFix, formatAutoFixResult } from './tools/auto-fix.js';
// Define the MCP tools
const TOOLS: Tool[] = [
{
name: 'compare_components',
description: 'Compare two React component files and get a structured diff showing changes in text, children, props, colors, spacing, and typography.',
inputSchema: {
type: 'object',
properties: {
oldFile: {
type: 'string',
description: 'Path to the original component file'
},
newFile: {
type: 'string',
description: 'Path to the modified component file'
}
},
required: ['oldFile', 'newFile']
}
},
{
name: 'analyze_component',
description: 'Analyze a single React component file and extract its structure, including component tree, props, and styles (Tailwind, styled-components, CSS modules).',
inputSchema: {
type: 'object',
properties: {
file: {
type: 'string',
description: 'Path to the component file to analyze'
}
},
required: ['file']
}
},
{
name: 'visual_diff_components',
description: 'Take screenshots of two React components and generate a visual diff image showing pixel-level differences. Returns paths to the generated images (old, new, diff, and combined side-by-side view).',
inputSchema: {
type: 'object',
properties: {
oldFile: {
type: 'string',
description: 'Path to the original component file (.tsx/.jsx)'
},
newFile: {
type: 'string',
description: 'Path to the modified component file (.tsx/.jsx)'
},
outputDir: {
type: 'string',
description: 'Optional directory to save the diff images. If not provided, uses a temp directory.'
},
width: {
type: 'number',
description: 'Viewport width for rendering (default: 800)'
},
height: {
type: 'number',
description: 'Viewport height for rendering (default: 600)'
}
},
required: ['oldFile', 'newFile']
}
},
{
name: 'get_figma_specs',
description: 'Extract design specifications from a Figma component including colors, spacing (padding, margin, gap), typography (font-size, font-weight, line-height), and dimensions.',
inputSchema: {
type: 'object',
properties: {
figmaUrl: {
type: 'string',
description: 'Full Figma URL (e.g., https://www.figma.com/file/ABC123/File?node-id=1:2). If provided, fileKey and nodeId are extracted automatically.'
},
fileKey: {
type: 'string',
description: 'Figma file key (alternative to figmaUrl)'
},
nodeId: {
type: 'string',
description: 'Figma node ID to analyze (format: 1:2). If not provided, analyzes the entire file.'
},
figmaToken: {
type: 'string',
description: 'Figma personal access token (required)'
}
},
required: ['figmaToken']
}
},
{
name: 'validate_design_implementation',
description: 'Compare Figma design specs with a React component implementation. Returns a validation report showing matches, mismatches, and missing properties for colors, spacing, typography, and borders.',
inputSchema: {
type: 'object',
properties: {
figmaUrl: {
type: 'string',
description: 'Full Figma URL to the design component'
},
fileKey: {
type: 'string',
description: 'Figma file key (alternative to figmaUrl)'
},
nodeId: {
type: 'string',
description: 'Figma node ID to compare against'
},
figmaToken: {
type: 'string',
description: 'Figma personal access token (required)'
},
componentFile: {
type: 'string',
description: 'Path to the React component file to validate'
},
tolerance: {
type: 'number',
description: 'Pixel tolerance for spacing comparisons (default: 2)'
}
},
required: ['figmaToken', 'componentFile']
}
},
{
name: 'review_branch',
description: 'Review all changed React components in a git branch. Analyzes structural changes, generates visual diffs, and optionally validates against Figma designs. Returns a comprehensive report.',
inputSchema: {
type: 'object',
properties: {
repoPath: {
type: 'string',
description: 'Path to the git repository'
},
baseBranch: {
type: 'string',
description: 'Base branch to compare against (default: main)'
},
targetBranch: {
type: 'string',
description: 'Target branch to review (default: HEAD)'
},
figmaToken: {
type: 'string',
description: 'Figma personal access token (optional, for design validation)'
},
figmaFileKey: {
type: 'string',
description: 'Figma file key containing the designs'
},
figmaMapping: {
type: 'object',
description: 'Mapping of component file paths to Figma node IDs (e.g., {"src/Button.tsx": "1:234"})'
},
outputDir: {
type: 'string',
description: 'Directory to save visual diff images'
}
},
required: ['repoPath']
}
},
{
name: 'validate_and_fix',
description: 'Validate a React component against Figma specs and generate auto-fix instructions. If the component does not match the design, returns a detailed prompt with exact Tailwind/CSS changes needed. Use this to automatically fix design discrepancies.',
inputSchema: {
type: 'object',
properties: {
figmaUrl: {
type: 'string',
description: 'Full Figma URL to the design component'
},
fileKey: {
type: 'string',
description: 'Figma file key (alternative to figmaUrl)'
},
nodeId: {
type: 'string',
description: 'Figma node ID to compare against'
},
figmaToken: {
type: 'string',
description: 'Figma personal access token (required)'
},
componentFile: {
type: 'string',
description: 'Path to the React component file to validate and fix'
},
tolerance: {
type: 'number',
description: 'Pixel tolerance for spacing comparisons (default: 2)'
}
},
required: ['figmaToken', 'componentFile']
}
}
];
// Create the MCP server
const server = new Server(
{
name: 'mcp-component-review',
version: '1.0.0'
},
{
capabilities: {
tools: {}
}
}
);
// Handle tool listing
server.setRequestHandler(ListToolsRequestSchema, async () => {
return { tools: TOOLS };
});
// Handle tool execution
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case 'compare_components': {
const input = args as { oldFile: string; newFile: string };
if (!input.oldFile || !input.newFile) {
return {
content: [
{
type: 'text',
text: 'Error: Both oldFile and newFile are required'
}
],
isError: true
};
}
const result = compareComponentFiles(input);
const formatted = formatComparisonResult(result);
return {
content: [
{
type: 'text',
text: formatted
}
]
};
}
case 'analyze_component': {
const input = args as { file: string };
if (!input.file) {
return {
content: [
{
type: 'text',
text: 'Error: file path is required'
}
],
isError: true
};
}
const result = analyzeComponent(input);
const formatted = formatAnalysisResult(result);
return {
content: [
{
type: 'text',
text: formatted
}
]
};
}
case 'visual_diff_components': {
const input = args as {
oldFile: string;
newFile: string;
outputDir?: string;
width?: number;
height?: number;
};
if (!input.oldFile || !input.newFile) {
return {
content: [
{
type: 'text',
text: 'Error: Both oldFile and newFile are required'
}
],
isError: true
};
}
const result = await visualDiffComponents(input);
const formatted = formatVisualDiffResult(result);
// Return both text report and the diff image
return {
content: [
{
type: 'text',
text: formatted
},
{
type: 'image',
data: result.diffImage,
mimeType: 'image/png'
}
]
};
}
case 'get_figma_specs': {
const input = args as {
figmaUrl?: string;
fileKey?: string;
nodeId?: string;
figmaToken: string;
};
if (!input.figmaToken) {
return {
content: [
{
type: 'text',
text: 'Error: figmaToken is required'
}
],
isError: true
};
}
if (!input.figmaUrl && !input.fileKey) {
return {
content: [
{
type: 'text',
text: 'Error: Either figmaUrl or fileKey is required'
}
],
isError: true
};
}
const result = await getFigmaSpecs(input);
const formatted = formatFigmaSpecsResult(result);
return {
content: [
{
type: 'text',
text: formatted
}
],
isError: !result.success
};
}
case 'validate_design_implementation': {
const input = args as {
figmaUrl?: string;
fileKey?: string;
nodeId?: string;
figmaToken: string;
componentFile: string;
tolerance?: number;
};
if (!input.figmaToken) {
return {
content: [
{
type: 'text',
text: 'Error: figmaToken is required'
}
],
isError: true
};
}
if (!input.componentFile) {
return {
content: [
{
type: 'text',
text: 'Error: componentFile is required'
}
],
isError: true
};
}
if (!input.figmaUrl && !input.fileKey) {
return {
content: [
{
type: 'text',
text: 'Error: Either figmaUrl or fileKey is required'
}
],
isError: true
};
}
const result = await validateDesignImplementation(input);
if (!result.success) {
return {
content: [
{
type: 'text',
text: `Error: ${result.error}`
}
],
isError: true
};
}
return {
content: [
{
type: 'text',
text: result.formatted || 'Validation complete'
}
]
};
}
case 'review_branch': {
const input = args as {
repoPath: string;
baseBranch?: string;
targetBranch?: string;
figmaToken?: string;
figmaFileKey?: string;
figmaMapping?: Record<string, string>;
outputDir?: string;
};
if (!input.repoPath) {
return {
content: [
{
type: 'text',
text: 'Error: repoPath is required'
}
],
isError: true
};
}
const result = await reviewBranch(input);
const formatted = formatBranchReviewResult(result);
return {
content: [
{
type: 'text',
text: formatted
}
]
};
}
case 'validate_and_fix': {
const input = args as {
figmaUrl?: string;
fileKey?: string;
nodeId?: string;
figmaToken: string;
componentFile: string;
tolerance?: number;
};
if (!input.figmaToken) {
return {
content: [
{
type: 'text',
text: 'Error: figmaToken is required'
}
],
isError: true
};
}
if (!input.componentFile) {
return {
content: [
{
type: 'text',
text: 'Error: componentFile is required'
}
],
isError: true
};
}
if (!input.figmaUrl && !input.fileKey) {
return {
content: [
{
type: 'text',
text: 'Error: Either figmaUrl or fileKey is required'
}
],
isError: true
};
}
const result = await validateAndFix(input);
const formatted = formatAutoFixResult(result);
// If fixes are needed, the prompt instructs the agent to apply them
return {
content: [
{
type: 'text',
text: formatted
}
],
// Mark as needing action if fixes are required
_meta: result.needsFix ? {
action_required: true,
fixes_count: result.fixes.length,
validation_score: result.validationScore
} : undefined
};
}
default:
return {
content: [
{
type: 'text',
text: `Unknown tool: ${name}`
}
],
isError: true
};
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: 'text',
text: `Error: ${errorMessage}`
}
],
isError: true
};
}
});
// Start the server
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('MCP Component Review server running on stdio');
// Cleanup on exit
process.on('SIGINT', async () => {
await cleanup();
process.exit(0);
});
process.on('SIGTERM', async () => {
await cleanup();
process.exit(0);
});
}
main().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});