EverArt Forge MCP Server
#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
ListResourcesRequestSchema,
ReadResourceRequestSchema,
McpError,
ErrorCode,
} from "@modelcontextprotocol/sdk/types.js";
import fetch from "node-fetch";
import open from "open";
import * as fs from "fs/promises";
import * as path from "path";
import { fileURLToPath } from "url";
import sharp from "sharp";
import { optimize } from "svgo";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const STORAGE_DIR = path.join(__dirname, "..", "images");
// Define error types
enum EverArtErrorType {
API_ERROR = "API_ERROR",
AUTHENTICATION_ERROR = "AUTHENTICATION_ERROR",
NETWORK_ERROR = "NETWORK_ERROR",
VALIDATION_ERROR = "VALIDATION_ERROR",
STORAGE_ERROR = "STORAGE_ERROR",
FORMAT_ERROR = "FORMAT_ERROR",
UNKNOWN_ERROR = "UNKNOWN_ERROR"
}
interface EverArtError {
type: EverArtErrorType;
message: string;
details?: any;
}
// Helper function for error responses
function errorResponse(error: EverArtError): { content: any[], isError: boolean } {
console.error(`[${error.type}] ${error.message}`, error.details || '');
return {
content: [
{ type: "text", text: `Error: ${error.message}` },
...(error.details ? [{ type: "text", text: `Details: ${JSON.stringify(error.details, null, 2)}` }] : []),
],
isError: true,
};
}
const server = new Server(
{
name: "everart-forge-mcp",
version: "0.1.0",
},
{
capabilities: {
tools: {},
resources: {},
},
},
);
// API key validation with better error message
if (!process.env.EVERART_API_KEY) {
console.error("ERROR: EVERART_API_KEY environment variable is not set. Please add your EverArt API key to the MCP settings.");
process.exit(1);
}
// Constants for retry logic
const MAX_RETRIES = 3;
const INITIAL_RETRY_DELAY = 1000; // 1 second
// Import and initialize EverArt client with better type definition
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const EverArt = require('everart');
interface EverArtClient {
v1: {
generations: {
create: (model: string, prompt: string, mode: string, options: any) => Promise<any[]>;
fetchWithPolling: (generationId: string, options?: { maxAttempts?: number, interval?: number }) => Promise<any>;
}
}
}
let client: EverArtClient;
try {
console.error("Initializing EverArt client...");
client = new EverArt.default(process.env.EVERART_API_KEY!);
console.error("EverArt client initialized successfully");
} catch (error) {
console.error("Failed to initialize EverArt client:", error);
console.error("Please check your API key and network connection.");
process.exit(1);
}
// Ensure storage directory exists with better error handling
async function ensureStorageDir() {
try {
await fs.mkdir(STORAGE_DIR, { recursive: true });
} catch (error) {
console.error("Failed to create storage directory:", error);
throw new McpError(
ErrorCode.InternalError,
`Failed to create storage directory: ${(error as Error).message}`
);
}
}
// Get the correct MIME type for a file format
function getMimeType(format: string): string {
switch (format.toLowerCase()) {
case 'svg':
return 'image/svg+xml';
case 'png':
return 'image/png';
case 'jpg':
case 'jpeg':
return 'image/jpeg';
case 'webp':
return 'image/webp';
default:
return 'application/octet-stream';
}
}
// Validate model and format compatibility
function validateModelFormatCompatibility(model: string, format: string): boolean {
// SVG is only supported by Recraft-Vector (8000)
if (format.toLowerCase() === 'svg' && model !== '8000') {
return false;
}
return true;
}
// Process and validate web project paths
async function processWebProjectPath(basePath?: string, projectType?: string, assetPath?: string): Promise<string | undefined> {
if (!basePath) return undefined;
try {
// Construct full path
let fullPath = basePath;
// If it's a web project, add appropriate structure
if (projectType) {
// Check for common web project structure patterns
switch (projectType.toLowerCase()) {
case 'react':
case 'vue':
case 'angular':
fullPath = path.join(basePath, 'public', assetPath || 'images');
break;
case 'next':
case 'nuxt':
fullPath = path.join(basePath, 'public', assetPath || 'images');
break;
case 'html':
case 'static':
default:
fullPath = path.join(basePath, assetPath || 'assets/images');
break;
}
} else if (assetPath) {
fullPath = path.join(basePath, assetPath);
}
// Ensure directory exists
await fs.mkdir(fullPath, { recursive: true });
return fullPath;
} catch (error) {
console.error(`Failed to process web project path: ${(error as Error).message}`);
return undefined;
}
}
// Enhanced image saving with better error handling and format validation
async function saveImage(imageUrl: string, prompt: string, model: string, format: string = "svg", outputPath?: string, webProjectPath?: string, projectType?: string, assetPath?: string): Promise<string> {
// Validate format
format = format.toLowerCase();
const supportedFormats = ['svg', 'png', 'jpg', 'jpeg', 'webp'];
if (!supportedFormats.includes(format)) {
throw new Error(`Unsupported format: ${format}. Supported formats are: ${supportedFormats.join(', ')}`);
}
// Validate model/format compatibility
if (!validateModelFormatCompatibility(model, format)) {
throw new Error(`Format '${format}' is not compatible with model '${model}'. SVG format is only available with Recraft-Vector (8000) model.`);
}
let filepath: string;
try {
// Handle web project paths if specified
let projectBasePath: string | undefined;
if (webProjectPath) {
projectBasePath = await processWebProjectPath(webProjectPath, projectType, assetPath);
}
// Generate a standardized file name for web assets
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const sanitizedPrompt = prompt.slice(0, 20).replace(/[^a-zA-Z0-9]/g, "_").toLowerCase();
const filename = `${sanitizedPrompt}_${model}.${format}`;
if (outputPath) {
// If outputPath is provided, ensure it has the correct extension
const ext = path.extname(outputPath);
if (!ext) {
// If no extension provided, append the format
filepath = `${outputPath}.${format}`;
} else if (ext.slice(1).toLowerCase() !== format.toLowerCase()) {
// If extension doesn't match format, warn but use the specified format
console.warn(`Warning: File extension ${ext} doesn't match specified format ${format}`);
filepath = outputPath.slice(0, -ext.length) + `.${format}`;
} else {
filepath = outputPath;
}
// Ensure the directory exists
await fs.mkdir(path.dirname(filepath), { recursive: true });
} else if (projectBasePath) {
// Web project path takes precedence over default
filepath = path.join(projectBasePath, filename);
} else {
// Default behavior: save to STORAGE_DIR with timestamp
filepath = path.join(STORAGE_DIR, `${timestamp}_${model}_${sanitizedPrompt}.${format}`);
}
// Fetch the image with retries
let response;
let retryCount = 0;
while (retryCount < MAX_RETRIES) {
try {
// @ts-ignore - node-fetch doesn't support timeout in RequestInit, but this works at runtime
response = await fetch(imageUrl, { timeout: 30000 });
if (response.ok) break;
// If we got a 429 (rate limit), wait longer before retrying
if (response.status === 429) {
const retryAfter = parseInt(response.headers.get('retry-after') || '5', 10);
await new Promise(r => setTimeout(r, retryAfter * 1000));
} else {
throw new Error(`Failed to fetch image: ${response.statusText} (${response.status})`);
}
} catch (error) {
if (retryCount >= MAX_RETRIES - 1) throw error;
// Exponential backoff
const delay = INITIAL_RETRY_DELAY * Math.pow(2, retryCount);
await new Promise(r => setTimeout(r, delay));
}
retryCount++;
}
if (!response || !response.ok) {
throw new Error(`Failed to fetch image after ${MAX_RETRIES} attempts`);
}
const buffer = await response.arrayBuffer();
const content = Buffer.from(buffer);
if (format === "svg") {
// For SVG, optimize and save
const svgString = content.toString('utf-8');
const result = optimize(svgString, {
multipass: true,
plugins: [
'preset-default',
'removeDimensions',
'removeViewBox',
'cleanupIds',
],
});
await fs.writeFile(filepath, result.data);
} else {
// For raster formats, convert using sharp with better error handling
try {
const image = sharp(content);
switch (format.toLowerCase()) {
case "png":
await image.png({ quality: 90 }).toFile(filepath);
break;
case "jpg":
case "jpeg":
await image.jpeg({ quality: 90 }).toFile(filepath);
break;
case "webp":
await image.webp({ quality: 90 }).toFile(filepath);
break;
default:
throw new Error(`Unsupported format: ${format}`);
}
} catch (error) {
throw new Error(`Image processing failed: ${(error as Error).message}`);
}
}
return filepath;
} catch (error) {
throw new Error(`Failed to save image: ${(error as Error).message}`);
}
}
// List stored images
async function listStoredImages(): Promise<string[]> {
try {
const files = await fs.readdir(STORAGE_DIR);
return files.filter((file: string) => /\.(svg|png|jpe?g|webp)$/i.test(file));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return [];
}
throw error;
}
}
// Helper for handling API errors in a user-friendly way
function handleApiError(error: any): EverArtError {
if (error.response) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
if (error.response.status === 401 || error.response.status === 403) {
return {
type: EverArtErrorType.AUTHENTICATION_ERROR,
message: "Authentication failed. Please check your API key.",
details: error.response.data
};
} else if (error.response.status === 429) {
return {
type: EverArtErrorType.API_ERROR,
message: "Rate limit exceeded. Please try again later.",
details: error.response.data
};
} else {
return {
type: EverArtErrorType.API_ERROR,
message: `API error: ${error.response.data?.message || error.response.statusText}`,
details: error.response.data
};
}
} else if (error.request) {
// The request was made but no response was received
return {
type: EverArtErrorType.NETWORK_ERROR,
message: "Network error. Failed to connect to EverArt API.",
details: error.message
};
} else {
// Something happened in setting up the request that triggered an Error
return {
type: EverArtErrorType.UNKNOWN_ERROR,
message: error.message || "Unknown error occurred",
details: error
};
}
}
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "generate_image",
description:
"Generate images using EverArt Models, optimized for web development. " +
"Supports web project paths, responsive formats, and inline preview. " +
"Available models:\n" +
"- 5000:FLUX1.1: Standard quality\n" +
"- 9000:FLUX1.1-ultra: Ultra high quality\n" +
"- 6000:SD3.5: Stable Diffusion 3.5\n" +
"- 7000:Recraft-Real: Photorealistic style\n" +
"- 8000:Recraft-Vector: Vector art style (SVG format)",
inputSchema: {
type: "object",
properties: {
prompt: {
type: "string",
description: "Text description of desired image",
},
model: {
type: "string",
description:
"Model ID (5000:FLUX1.1, 9000:FLUX1.1-ultra, 6000:SD3.5, 7000:Recraft-Real, 8000:Recraft-Vector)",
default: "5000",
},
format: {
type: "string",
description: "Output format (svg, png, jpg, webp). Note: Vector format (svg) is only available with Recraft-Vector (8000) model.",
default: "svg"
},
output_path: {
type: "string",
description: "Optional: Custom output path for the generated image. If not provided, image will be saved in the default storage directory.",
},
web_project_path: {
type: "string",
description: "Path to web project root folder for storing images in appropriate asset directories.",
},
project_type: {
type: "string",
description: "Web project type to determine appropriate asset directory structure (e.g., 'react', 'vue', 'html', 'next').",
},
asset_path: {
type: "string",
description: "Optional subdirectory within the web project's asset structure for storing generated images.",
},
image_count: {
type: "number",
description: "Number of images to generate",
default: 1,
},
},
required: ["prompt"],
},
},
{
name: "list_images",
description: "List all stored images",
inputSchema: {
type: "object",
properties: {},
},
},
{
name: "view_image",
description: "Open a stored image in the default image viewer",
inputSchema: {
type: "object",
properties: {
filename: {
type: "string",
description: "Name of the image file to view",
},
},
required: ["filename"],
},
},
],
}));
server.setRequestHandler(ListResourcesRequestSchema, async () => {
try {
const files = await listStoredImages();
return {
resources: files.map(file => {
// Determine correct MIME type based on file extension
const ext = path.extname(file).slice(1).toLowerCase();
const mimeType = getMimeType(ext);
return {
uri: `everart-forge-mcp://images/${file}`,
mimeType,
name: file,
};
}),
};
} catch (error) {
console.error("Failed to list resources:", error);
throw new McpError(
ErrorCode.InternalError,
`Failed to list image resources: ${(error as Error).message}`
);
}
});
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const match = request.params.uri.match(/^everart-forge-mcp:\/\/images\/(.+)$/);
if (!match) {
throw new McpError(
ErrorCode.InvalidRequest,
`Invalid URI format: ${request.params.uri}. Expected format: everart-forge-mcp://images/filename`
);
}
const filename = match[1];
const filepath = path.join(STORAGE_DIR, filename);
try {
const content = await fs.readFile(filepath);
// Determine correct MIME type based on file extension
const ext = path.extname(filename).slice(1).toLowerCase();
const mimeType = getMimeType(ext);
return {
contents: [
{
uri: request.params.uri,
mimeType,
blob: content.toString("base64"),
},
],
};
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
throw new McpError(
404, // Use standard HTTP 404 code
`Image not found: ${filename}. Please check if the file exists in the storage directory.`
);
}
throw new McpError(
ErrorCode.InternalError,
`Failed to read image: ${(error as Error).message}`
);
}
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
await ensureStorageDir();
} catch (error) {
return errorResponse({
type: EverArtErrorType.STORAGE_ERROR,
message: `Failed to ensure storage directory: ${(error as Error).message}`
});
}
switch (request.params.name) {
case "generate_image": {
try {
const args = request.params.arguments as any;
// Validate required parameters
if (!args.prompt || typeof args.prompt !== 'string' || args.prompt.trim() === '') {
return errorResponse({
type: EverArtErrorType.VALIDATION_ERROR,
message: "Prompt is required and must be a non-empty string."
});
}
const prompt = args.prompt;
// Use 'let' instead of 'const' for model since we might need to modify it
let modelInput = args.model || "5000";
const image_count = args.image_count || 1;
const output_path = args.output_path;
const web_project_path = args.web_project_path;
const project_type = args.project_type;
const asset_path = args.asset_path;
// Enhanced validation
if (image_count < 1 || image_count > 10) {
return errorResponse({
type: EverArtErrorType.VALIDATION_ERROR,
message: "image_count must be between 1 and 10"
});
}
// Validate model - extract the numeric ID if a combined format was provided
const validModels = ["5000", "6000", "7000", "8000", "9000"];
// Handle model IDs in the format "8000:Recraft-Vector"
if (modelInput.includes(":")) {
const originalModel = modelInput;
modelInput = modelInput.split(":")[0];
console.log(`Received combined model ID format: ${originalModel}, using base ID: ${modelInput}`);
}
if (!validModels.includes(modelInput)) {
return errorResponse({
type: EverArtErrorType.VALIDATION_ERROR,
message: `Invalid model ID: ${modelInput}. Valid models are: ${validModels.join(", ")}`
});
}
// Now we have the validated model ID
const format = args.format || (modelInput === "8000" ? "svg" : "png");
// Validate format
const supportedFormats = ["svg", "png", "jpg", "jpeg", "webp"];
if (!supportedFormats.includes(format.toLowerCase())) {
return errorResponse({
type: EverArtErrorType.VALIDATION_ERROR,
message: `Unsupported format: ${format}. Supported formats are: ${supportedFormats.join(", ")}`
});
}
// Validate model/format compatibility
if (!validateModelFormatCompatibility(modelInput, format)) {
return errorResponse({
type: EverArtErrorType.VALIDATION_ERROR,
message: `Format '${format}' is not compatible with model '${modelInput}'. SVG format is only available with Recraft-Vector (8000) model.`
});
}
// Generate image with retry logic
let generation;
let retryCount = 0;
while (retryCount < MAX_RETRIES) {
try {
generation = await client.v1.generations.create(
modelInput,
prompt,
"txt2img",
{
imageCount: image_count,
height: 1024,
width: 1024,
// Add extra fields for specific models if needed
...(modelInput === "8000" ? { variant: "vector" } : {}),
},
);
break;
} catch (error) {
if (retryCount >= MAX_RETRIES - 1) throw error;
// Exponential backoff
const delay = INITIAL_RETRY_DELAY * Math.pow(2, retryCount);
await new Promise(r => setTimeout(r, delay));
retryCount++;
}
}
if (!generation) {
throw new Error("Failed to create generation after multiple attempts");
}
// Enhanced polling with better timeout handling
const completedGen = await client.v1.generations.fetchWithPolling(
generation[0].id,
{
maxAttempts: 30, // Increased from default
interval: 3000 // Check every 3 seconds
}
);
const imgUrl = completedGen.image_url;
if (!imgUrl) {
throw new Error("No image URL in the completed generation");
}
// Save image locally with specified format and path
const filepath = await saveImage(
imgUrl,
prompt,
modelInput,
format,
output_path,
web_project_path,
project_type,
asset_path
);
// Open in default viewer
try {
await open(filepath);
} catch (openError) {
console.warn("Could not open the image in default viewer:", openError);
// Continue without throwing - this is a non-critical error
}
// Model name mapping for user-friendly display
const modelNames: Record<string, string> = {
"5000": "FLUX1.1 (Standard quality)",
"9000": "FLUX1.1-ultra (Ultra high quality)",
"6000": "Stable Diffusion 3.5",
"7000": "Recraft-Real (Photorealistic)",
"8000": "Recraft-Vector (Vector art)"
};
// Read the image file for inline display
let imageData: string | undefined;
try {
const imageContent = await fs.readFile(filepath);
imageData = imageContent.toString('base64');
} catch (error) {
console.warn("Unable to read image for inline display:", error);
// Continue without inline display if reading fails
}
// Calculate relative web path if applicable
let webRelativePath: string | undefined;
if (web_project_path && filepath.startsWith(web_project_path)) {
webRelativePath = filepath.slice(web_project_path.length);
if (!webRelativePath.startsWith('/')) webRelativePath = '/' + webRelativePath;
}
return {
content: [
{
type: "text",
text: `✅ Image generated and saved successfully!\n\n` +
`Generation details:\n` +
`• Model: ${modelNames[modelInput] || modelInput}\n` +
`• Prompt: "${prompt}"\n` +
`• Format: ${format.toUpperCase()}\n` +
`• Saved to: ${filepath}` +
(webRelativePath ? `\n• Web relative path: ${webRelativePath}` : ``)
},
{
type: "text",
text: `View the image at: file://${filepath}`
}
],
};
} catch (error: unknown) {
console.error("Detailed error:", error);
// Categorize errors for better user feedback
if (error instanceof Error) {
if (error.message.includes("SVG format")) {
return errorResponse({
type: EverArtErrorType.FORMAT_ERROR,
message: error.message
});
} else if (error.message.includes("Failed to fetch image")) {
return errorResponse({
type: EverArtErrorType.NETWORK_ERROR,
message: "Failed to download the generated image. Please check your internet connection and try again."
});
} else if (error.message.includes("rate limit")) {
return errorResponse({
type: EverArtErrorType.API_ERROR,
message: "EverArt API rate limit reached. Please try again later."
});
} else if (error.message.includes("unauthorized") || error.message.includes("authentication")) {
return errorResponse({
type: EverArtErrorType.AUTHENTICATION_ERROR,
message: "API authentication failed. Please check your EverArt API key."
});
}
}
// Generic error handling
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return errorResponse({
type: EverArtErrorType.UNKNOWN_ERROR,
message: errorMessage
});
}
}
case "list_images": {
try {
const files = await listStoredImages();
if (files.length === 0) {
return {
content: [{ type: "text", text: "No stored images found. Try generating some images first!" }],
};
}
// Group files by type for better display
const filesByType: Record<string, string[]> = {};
for (const file of files) {
const ext = path.extname(file).slice(1).toLowerCase();
if (!filesByType[ext]) {
filesByType[ext] = [];
}
filesByType[ext].push(file);
}
let resultText = "📁 Stored images:\n\n";
for (const [type, typeFiles] of Object.entries(filesByType)) {
resultText += `${type.toUpperCase()} Files (${typeFiles.length}):\n`;
resultText += typeFiles.map(f => `• ${f}`).join("\n");
resultText += "\n\n";
}
resultText += `Total: ${files.length} file(s)`;
// Add file URLs instead of trying to embed images
const recentFiles = files.slice(-5);
const fileUrls: string[] = [];
for (const file of recentFiles) {
const filepath = path.join(STORAGE_DIR, file);
fileUrls.push(`file://${filepath}`);
}
return {
content: [
{ type: "text", text: resultText },
...(fileUrls.length > 0 ? [{
type: "text",
text: "\nRecent images:\n" + fileUrls.map(url => `• ${url}`).join('\n')
}] : []),
],
};
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return errorResponse({
type: EverArtErrorType.STORAGE_ERROR,
message: `Error listing images: ${errorMessage}`
});
}
}
case "view_image": {
try {
const args = request.params.arguments as any;
// Validate filename
if (!args.filename || typeof args.filename !== 'string') {
return errorResponse({
type: EverArtErrorType.VALIDATION_ERROR,
message: "filename is required and must be a string"
});
}
const filename = args.filename;
const filepath = path.join(STORAGE_DIR, filename);
try {
// Check if file exists
await fs.access(filepath);
} catch (accessError) {
// List available files to help the user
const availableFiles = await listStoredImages();
let errorMsg = `Image not found: ${filename}`;
if (availableFiles.length > 0) {
const suggestions = availableFiles
.filter(f => f.toLowerCase().includes(filename.toLowerCase()) ||
filename.toLowerCase().includes(f.toLowerCase().split('_').pop() || ''))
.slice(0, 3);
if (suggestions.length > 0) {
errorMsg += `\n\nDid you mean one of these?\n` +
suggestions.map(s => `• ${s}`).join('\n');
}
errorMsg += `\n\nUse 'list_images' to see all available images.`;
}
return errorResponse({
type: EverArtErrorType.VALIDATION_ERROR,
message: errorMsg
});
}
// Read the image for inline display
let imageData: string | undefined;
let mimeType: string = 'application/octet-stream';
try {
const content = await fs.readFile(filepath);
imageData = content.toString('base64');
const ext = path.extname(filename).slice(1).toLowerCase();
mimeType = getMimeType(ext);
} catch (error) {
console.warn("Unable to read image for inline display:", error);
// Continue without inline display if reading fails
}
await open(filepath);
// Skip opening in external viewer since we'll show in MCP
try {
// If we got here, cancel the auto-open to avoid duplicate windows
// await open(filepath);
} catch (openError) {
// Ignore error
}
return {
content: [
{
type: "text",
text: `✅ Viewing image: ${filename}`
},
{
type: "text",
text: `Image opened in default viewer.\nFile path: file://${filepath}`
}
],
};
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return errorResponse({
type: EverArtErrorType.UNKNOWN_ERROR,
message: `Error viewing image: ${errorMessage}`
});
}
}
default:
return errorResponse({
type: EverArtErrorType.VALIDATION_ERROR,
message: `Unknown tool: ${request.params.name}`
});
}
});
async function runServer() {
await ensureStorageDir();
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("EverArt Forge MCP Server running on stdio");
}
runServer().catch(console.error);