draw-it-mcp
Version:
🎨 A beautiful drawing app with Cursor & Claude Code MCP integration. Draw, save, and let AI analyze your artwork!
525 lines (453 loc) • 17.9 kB
JavaScript
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
ListResourcesRequestSchema,
ListPromptsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import sharp from 'sharp';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import os from 'os';
import winston from 'winston';
import { mcpLogger } from '../utils/logger.js';
// MCP-only logger that doesn't output to console to avoid stdio conflicts
const logger = winston.createLogger({
level: 'debug',
transports: [
new winston.transports.File({
filename: '/tmp/mcp-debug.log',
level: 'debug'
})
]
});
// Get the directory of this script for reliable path resolution
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
class DrawingMCPServer {
constructor() {
this.server = new Server(
{
name: 'draw-it-mcp',
version: '1.0.0',
},
{
capabilities: {
tools: {},
resources: {},
prompts: {},
},
}
);
this.setupHandlers();
}
// Helper method to get local IP address
getLocalIP() {
const interfaces = os.networkInterfaces();
for (const name of Object.keys(interfaces)) {
for (const iface of interfaces[name]) {
if (iface.family === 'IPv4' && !iface.internal) {
return iface.address;
}
}
}
return 'localhost';
}
// Helper method to get the correct drawings directory path
getDrawingsPath() {
// Try multiple possible paths to find the drawings directory
const possiblePaths = [
// From script location (src/mcp/) go up to project root
path.join(__dirname, '..', '..', 'public', 'drawings'),
// From current working directory
path.join(process.cwd(), 'public', 'drawings'),
// Alternative path if running from different location
path.join(process.cwd(), 'src', '..', 'public', 'drawings'),
];
for (const drawingsPath of possiblePaths) {
const resolvedPath = path.resolve(drawingsPath);
logger.debug(`[MCP] Checking drawings path: ${resolvedPath}`);
if (fs.existsSync(resolvedPath)) {
logger.info(`[MCP] Found drawings directory at: ${resolvedPath}`);
return resolvedPath;
}
}
// If none found, use the first one as fallback
const fallbackPath = path.resolve(possiblePaths[0]);
logger.warn(`[MCP] No drawings directory found, using fallback: ${fallbackPath}`);
return fallbackPath;
}
// Validate JSON response before sending
validateAndLogResponse(response, requestType) {
try {
const jsonString = JSON.stringify(response);
JSON.parse(jsonString); // Validate JSON
mcpLogger.debug(`MCP Response - ${requestType} - JSON Valid`, {
response,
jsonLength: jsonString.length
});
return response;
} catch (error) {
mcpLogger.error(`MCP Response - ${requestType} - JSON Invalid`, {
error: error.message,
response
});
throw new Error(`Invalid JSON response for ${requestType}: ${error.message}`);
}
}
setupHandlers() {
// List available tools
this.server.setRequestHandler(ListToolsRequestSchema, async (request) => {
mcpLogger.info('MCP Request - ListTools', { request });
const response = {
tools: [
{
name: 'get_drawing_png',
description: "Retrieve and analyze the current drawing image. Automatically applies smart cropping to remove whitespace and resizes to 640x640(max) for efficient analysis. Use this tool when users want you to see their drawings to: examine drawing content, recreate drawings in HTML/CSS, analyze visual elements, convert drawings to code, or get detailed information about shapes, lines, and text in the image. When users request web design analysis, examine element sizes, positions, functionality, and interactions to ensure they align well with overall layout and UX intent. For diagrams (such as Mermaid charts), focus on the structure of boxes, arrows, and logical groupings to understand relationships before interpretation. For character or object designs, analyse posture, shape, and colour to capture style and identify key features needed for accurate recreation. Beyond basic drawing recognition, also extract layout components like divs and buttons for HTML/CSS conversion by analysing visual grouping and sizing. When analysing UI mockups, evaluate design consistency and interaction flow. For structural diagrams, identify nodes and connections to reconstruct logical structure. For characters or objects, assess visual balance and stylistic intent to suggest faithful or improved renditions. Note: This tool provides larger images and faster performance compared to 'get_drawing_base64', so it's recommended as the primary choice. However, access to saved PNG files may be blocked depending on system settings, in which case 'get_drawing_base64' is recommended as an alternative.",
inputSchema: {
type: 'object',
properties: {},
required: [],
}
},
{
name: 'get_drawing_base64',
description: "Retrieve the current drawing as a base64-encoded image. Automatically applies smart cropping to remove whitespace and resizes to 128x128(max) for efficient transfer. Returns the image data as a base64 string suitable for embedding. Note: This tool has lower resolution and slower performance compared to 'get_drawing_png'. It is recommended to use this tool only as a fallback when 'get_drawing_png' fails.",
inputSchema: {
type: 'object',
properties: {},
required: [],
}
}
]
};
return this.validateAndLogResponse(response, 'ListTools');
});
// List available resources
this.server.setRequestHandler(ListResourcesRequestSchema, async (request) => {
mcpLogger.info('MCP Request - ListResources', { request });
const response = {
resources: []
};
return this.validateAndLogResponse(response, 'ListResources');
});
// List available prompts
this.server.setRequestHandler(ListPromptsRequestSchema, async (request) => {
mcpLogger.info('MCP Request - ListPrompts', { request });
const response = {
prompts: []
};
return this.validateAndLogResponse(response, 'ListPrompts');
});
// Handle tool calls
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
mcpLogger.info('MCP Request - CallTool', {
tool: name,
arguments: args,
timestamp: new Date().toISOString()
});
try {
let response;
switch (name) {
case 'get_drawing_png':
response = await this.getDrawingPng();
break;
case 'get_drawing_base64':
response = await this.getDrawingBase64();
break;
default:
throw new Error(`Unknown tool: ${name}`);
}
mcpLogger.info('MCP Response - CallTool', {
tool: name,
success: true,
responseSize: JSON.stringify(response).length,
timestamp: new Date().toISOString()
});
return this.validateAndLogResponse(response, `CallTool-${name}`);
} catch (error) {
const errorResponse = {
content: [
{
type: 'text',
text: `Error: ${error.message}`,
},
],
};
mcpLogger.error('MCP Response - CallTool Error', {
tool: name,
error: error.message,
timestamp: new Date().toISOString()
});
return this.validateAndLogResponse(errorResponse, `CallTool-${name}-Error`);
}
});
}
// Smart crop function using Sharp to remove whitespace
async smartCrop(imagePath) {
try {
const image = sharp(imagePath);
const { width, height } = await image.metadata();
// Use Sharp's trim feature to remove whitespace automatically
// This removes pixels that are similar to the corner pixels
const trimmed = await image
.trim({ threshold: 10 }) // Remove pixels within 10% similarity to corners
.toBuffer();
const trimmedImage = sharp(trimmed);
const { width: croppedWidth, height: croppedHeight } = await trimmedImage.metadata();
return {
image: trimmedImage,
originalSize: { width, height },
croppedSize: { width: croppedWidth, height: croppedHeight }
};
} catch (error) {
logger.error('[MCP] Smart crop failed:', error);
// Fallback to center crop
const image = sharp(imagePath);
const { width, height } = await image.metadata();
const size = Math.min(width, height);
const x = Math.floor((width - size) / 2);
const y = Math.floor((height - size) / 2);
const centerCropped = await image
.extract({ left: x, top: y, width: size, height: size })
.toBuffer();
return {
image: sharp(centerCropped),
originalSize: { width, height },
croppedSize: { width: size, height: size }
};
}
}
async getDrawingPng() {
logger.debug('[MCP] Get drawing png called with smart cropping');
try {
const response = await fetch('http://localhost:3001/api/drawing');
if (!response.ok) {
return {
content: [
{
type: 'text',
text: `No drawing data available. App status: ${response.status}. Start the drawing app: npm run dev`,
},
],
};
}
const data = await response.json();
if (!data.filePath) {
return {
content: [
{
type: 'text',
text: 'No drawing found. Please create a drawing at http://localhost:3001 first.',
},
],
};
}
// Get PNG file path
const drawingsDir = this.getDrawingsPath();
const filename = data.filePath ? path.basename(data.filePath) : 'current-active.png';
const imagePath = path.join(drawingsDir, filename);
logger.debug(`[MCP] Looking for image file:`, {
drawingsDir,
filename,
imagePath,
exists: fs.existsSync(imagePath)
});
if (!fs.existsSync(imagePath)) {
// List files in the drawings directory for debugging
try {
const files = fs.readdirSync(drawingsDir);
logger.debug(`[MCP] Files in drawings directory: ${files.join(', ')}`);
} catch (error) {
logger.error(`[MCP] Cannot read drawings directory: ${error.message}`);
}
return {
content: [
{
type: 'text',
text: `Image file not found: ${imagePath}. Drawings directory: ${drawingsDir}`,
},
],
};
}
// Smart crop to remove whitespace using Sharp
const cropResult = await this.smartCrop(imagePath);
// Calculate optimal size while maintaining aspect ratio
const { width: croppedWidth, height: croppedHeight } = cropResult.croppedSize;
const aspectRatio = croppedWidth / croppedHeight;
// Determine target dimensions based on aspect ratio
// Keep the longer side at 640px, scale the shorter side proportionally
let targetWidth, targetHeight;
const maxSize = 640;
if (aspectRatio > 1) {
// Landscape: width is longer
targetWidth = maxSize;
targetHeight = Math.round(maxSize / aspectRatio);
} else {
// Portrait or square: height is longer or equal
targetHeight = maxSize;
targetWidth = Math.round(maxSize * aspectRatio);
}
// Save the resized image to last_mcp_transfer.png
const transferFilePath = path.join(drawingsDir, 'last_mcp_transfer.png');
await cropResult.image
.resize(targetWidth, targetHeight, {
fit: 'fill', // Exact dimensions, no padding
withoutEnlargement: false
})
.png()
.toFile(transferFilePath);
// Create file:// URL for the saved image
const fileUrl = `file://${transferFilePath}`;
// Debug logging
logger.debug('[MCP] Response data:', {
cropResult,
targetWidth,
targetHeight,
fileUrl
});
const textContent = {
type: 'text',
text: `Drawing optimized successfully.\nOriginal: ${cropResult?.originalSize?.width || 'unknown'}x${cropResult?.originalSize?.height || 'unknown'}\nCropped: ${cropResult?.croppedSize?.width || 'unknown'}x${cropResult?.croppedSize?.height || 'unknown'}\nFinal: ${targetWidth || 'unknown'}x${targetHeight || 'unknown'}\nFile URI: ${fileUrl}`,
};
const finalResponse = {
content: [textContent],
};
// Validate each content item
finalResponse.content.forEach((item, index) => {
if (!item || typeof item !== 'object') {
logger.error(`[MCP] Content item ${index} is invalid:`, item);
}
if (!item.type) {
logger.error(`[MCP] Content item ${index} missing type:`, item);
}
});
return this.validateAndLogResponse(finalResponse, 'CallTool-get_drawing_png');
} catch (error) {
return {
content: [
{
type: 'text',
text: `Failed to get optimized drawing data: ${error.message}`,
},
],
};
}
}
async getDrawingBase64() {
logger.debug('[MCP] Get drawing base64 called with 64x64 max size');
try {
const response = await fetch('http://localhost:3001/api/drawing');
if (!response.ok) {
return {
content: [
{
type: 'text',
text: `No drawing data available. App status: ${response.status}. Start the drawing app: npm run dev`,
},
],
};
}
const data = await response.json();
if (!data.filePath) {
return {
content: [
{
type: 'text',
text: 'No drawing found. Please create a drawing at http://localhost:3001 first.',
},
],
};
}
// Get PNG file path
const drawingsDir = this.getDrawingsPath();
const filename = data.filePath ? path.basename(data.filePath) : 'current-active.png';
const imagePath = path.join(drawingsDir, filename);
logger.debug(`[MCP] Looking for image file:`, {
drawingsDir,
filename,
imagePath,
exists: fs.existsSync(imagePath)
});
if (!fs.existsSync(imagePath)) {
return {
content: [
{
type: 'text',
text: `Image file not found: ${imagePath}. Drawings directory: ${drawingsDir}`,
},
],
};
}
// Smart crop to remove whitespace using Sharp
const cropResult = await this.smartCrop(imagePath);
// Calculate optimal size while maintaining aspect ratio
const { width: croppedWidth, height: croppedHeight } = cropResult.croppedSize;
const aspectRatio = croppedWidth / croppedHeight;
// Determine target dimensions based on aspect ratio
// Keep the longer side at 128px, scale the shorter side proportionally
let targetWidth, targetHeight;
const maxSize = 128;
if (aspectRatio > 1) {
// Landscape: width is longer
targetWidth = maxSize;
targetHeight = Math.round(maxSize / aspectRatio);
} else {
// Portrait or square: height is longer or equal
targetHeight = maxSize;
targetWidth = Math.round(maxSize * aspectRatio);
}
// Resize and convert to base64
const base64Buffer = await cropResult.image
.resize(targetWidth, targetHeight, {
fit: 'fill', // Exact dimensions, no padding
withoutEnlargement: false
})
.png()
.toBuffer();
const base64String = base64Buffer.toString('base64');
// Debug logging
logger.debug('[MCP] Base64 response data:', {
originalSize: cropResult.originalSize,
croppedSize: cropResult.croppedSize,
targetWidth,
targetHeight,
base64Length: base64String.length
});
// Return in the same format as the image server example
return {
content: [
{
type: 'image',
data: base64String,
mimeType: 'image/png',
},
],
};
} catch (error) {
return {
content: [
{
type: 'text',
text: `Failed to get drawing as base64: ${error.message}`,
},
],
};
}
}
async run() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
// Only log to files, not console for MCP stdio compatibility
logger.info('Draw-it MCP Server running with stdio transport');
}
}
const server = new DrawingMCPServer();
server.run().catch((error) => {
logger.error('MCP Server failed to start:', error);
process.exit(1);
});