Skip to main content
Glama

Etsy SEO Generator

mcp-server.jsโ€ข20.4 kB
#!/usr/bin/env node /** * Etsy SEO MCP Server * Model Context Protocol server for Claude Desktop integration */ import crypto from 'node:crypto'; // Simple MCP stdio communication process.stdin.setEncoding('utf8'); let buffer = ''; let userEmail = process.env.EMAIL || null; const apiBase = process.env.API_BASE || 'https://devqora.space'; const apiPath = process.env.API_PATH || '/api/generate'; const API_ENDPOINT = new URL(apiPath, apiBase).toString(); const apiSecret = process.env.MCP_API_SECRET || null; const NODE_ENV = process.env.NODE_ENV || 'production'; // Security constants const TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000; // 5 minutes const REQUEST_CACHE_SIZE = 1000; const REQUEST_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes const requestCache = new Map(); const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute const RATE_LIMIT_MAX_REQUESTS = 10; const rateLimitMap = new Map(); const ipRateLimitMap = new Map(); // IP-based rate limiting const LOG_LEVEL = process.env.LOG_LEVEL || 'error'; // API Secret Security const MIN_SECRET_LENGTH = 32; const MIN_SECRET_ENTROPY = 3.5; // bits per character // Input validation constants const MAX_BUFFER_SIZE = 1024 * 1024; // 1MB const MAX_INPUT_LENGTH = 10000; const MAX_PRODUCT_NAME_LENGTH = 500; const MAX_CATEGORY_LENGTH = 200; const MAX_EMAIL_LENGTH = 254; // RFC 5321 // SSRF Protection - Whitelist allowed API hosts const ALLOWED_API_HOSTS = [ 'devqora.space', 'api.devqora.space', 'localhost', '127.0.0.1' ]; function log(level, message, data = null) { const levels = { debug: 0, info: 1, warn: 2, error: 3 }; const currentLevel = levels[LOG_LEVEL] || levels.error; if (levels[level] >= currentLevel) { if (data && level === 'error') { console.error(`[${level.toUpperCase()}]`, message); } else if (data) { console.error(`[${level.toUpperCase()}]`, message, data); } else { console.error(`[${level.toUpperCase()}]`, message); } } } // Calculate Shannon entropy for password strength function calculateEntropy(str) { const len = str.length; const frequencies = {}; for (let i = 0; i < len; i++) { const char = str[i]; frequencies[char] = (frequencies[char] || 0) + 1; } let entropy = 0; for (const char in frequencies) { const p = frequencies[char] / len; entropy -= p * Math.log2(p); } return entropy; } // Validate API secret strength function validateApiSecret(secret) { if (!secret) { throw new Error('MCP_API_SECRET is required. Please set it in your environment configuration.'); } if (typeof secret !== 'string') { throw new Error('MCP_API_SECRET must be a string'); } if (secret.length < MIN_SECRET_LENGTH) { throw new Error(`MCP_API_SECRET must be at least ${MIN_SECRET_LENGTH} characters long. Current length: ${secret.length}`); } const entropy = calculateEntropy(secret); if (entropy < MIN_SECRET_ENTROPY) { throw new Error(`MCP_API_SECRET is too weak. Use a mix of characters for better security. (Entropy: ${entropy.toFixed(2)} bits/char, minimum: ${MIN_SECRET_ENTROPY})`); } return true; } function validateEmail(email) { // Length check first to prevent ReDoS if (!email || email.length > MAX_EMAIL_LENGTH) { return false; } // Simple, non-backtracking regex to prevent ReDoS const emailRegex = /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; return emailRegex.test(email); } function validateInput(value, maxLength, fieldName) { if (typeof value !== 'string') { throw new Error(`${fieldName} must be a string`); } if (value.length > maxLength) { throw new Error(`${fieldName} exceeds maximum length of ${maxLength} characters`); } return value; } function validateApiEndpoint(url) { try { const parsedUrl = new URL(url); // Only allow HTTPS in production (or HTTP for localhost in development) if (NODE_ENV === 'production' && parsedUrl.protocol !== 'https:') { throw new Error('HTTPS is required in production environment'); } if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') { throw new Error('Invalid protocol. Only HTTPS (and HTTP for localhost) is allowed.'); } // Check if hostname is in whitelist if (!ALLOWED_API_HOSTS.includes(parsedUrl.hostname)) { throw new Error(`API host ${parsedUrl.hostname} is not in the allowed list`); } return true; } catch (error) { throw new Error(`Invalid API endpoint: ${error.message}`); } } function cleanupCaches() { const now = Date.now(); // Cleanup request cache for (const [key, timestamp] of requestCache.entries()) { if (now - timestamp > REQUEST_CACHE_TTL_MS) { requestCache.delete(key); } } // Cleanup email-based rate limit map for (const [key, timestamps] of rateLimitMap.entries()) { const recentRequests = timestamps.filter(t => now - t < RATE_LIMIT_WINDOW_MS); if (recentRequests.length === 0) { rateLimitMap.delete(key); } else { rateLimitMap.set(key, recentRequests); } } // Cleanup IP-based rate limit map for (const [key, timestamps] of ipRateLimitMap.entries()) { const recentRequests = timestamps.filter(t => now - t < RATE_LIMIT_WINDOW_MS); if (recentRequests.length === 0) { ipRateLimitMap.delete(key); } else { ipRateLimitMap.set(key, recentRequests); } } } function checkRateLimit(identifier, isIP = false) { const now = Date.now(); const limitMap = isIP ? ipRateLimitMap : rateLimitMap; const userRequests = limitMap.get(identifier) || []; // Remove old requests outside the window const recentRequests = userRequests.filter(timestamp => now - timestamp < RATE_LIMIT_WINDOW_MS); if (recentRequests.length >= RATE_LIMIT_MAX_REQUESTS) { return false; } recentRequests.push(now); limitMap.set(identifier, recentRequests); return true; } // Aggressive cache cleanup for high traffic (every 2 minutes) setInterval(cleanupCaches, 2 * 60 * 1000); function validateTimestamp(timestamp, payloadHash) { const now = Date.now(); const requestTime = parseInt(timestamp, 10); if (isNaN(requestTime)) { throw new Error('Invalid timestamp format'); } if (requestTime < 0 || requestTime > now + TIMESTAMP_TOLERANCE_MS) { throw new Error('Invalid timestamp value'); } const timeDiff = Math.abs(now - requestTime); if (timeDiff > TIMESTAMP_TOLERANCE_MS) { throw new Error('Request timestamp outside acceptable window'); } // Prevent replay attacks with timestamp + payload hash combination const requestId = `${timestamp}:${payloadHash}`; if (requestCache.has(requestId)) { throw new Error('Duplicate request detected'); } requestCache.set(requestId, now); // Limit cache size by removing oldest entries if (requestCache.size > REQUEST_CACHE_SIZE) { const oldestKey = requestCache.keys().next().value; requestCache.delete(oldestKey); } } function generateSignature(payload) { validateApiSecret(apiSecret); const timestamp = Date.now().toString(); const payloadString = JSON.stringify(payload); // Create a hash of the payload for replay protection const payloadHash = crypto .createHash('sha256') .update(payloadString) .digest('hex') .substring(0, 16); // Use first 16 chars as identifier const message = payloadString + timestamp; const signature = crypto .createHmac('sha256', apiSecret) .update(message) .digest('hex'); validateTimestamp(timestamp, payloadHash); return { signature, timestamp }; } function sanitizeText(value) { if (typeof value !== 'string') { return ''; } return value .replace(/[\u0000-\u001F\u007F]/g, '') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .trim(); } function validateApiResponse(data) { if (!data || typeof data !== 'object') { throw new Error('Malformed API response'); } if (!data.success || typeof data.data !== 'object') { throw new Error(data.error || 'Generation failed'); } const { title, description, tags, suggested_price_range: priceRange } = data.data; if (typeof title !== 'string' || !title.trim()) { throw new Error('API response missing title'); } if (typeof description !== 'string' || !description.trim()) { throw new Error('API response missing description'); } if (!Array.isArray(tags) || tags.some(tag => typeof tag !== 'string')) { throw new Error('API response tags must be an array of strings'); } if (typeof priceRange !== 'string') { throw new Error('API response missing price suggestion'); } let usage = null; if (data.usage && typeof data.usage === 'object') { const { current, limit, remaining } = data.usage; const isValidNumber = (value) => typeof value === 'number' && Number.isFinite(value); if ([current, limit, remaining].every(isValidNumber)) { usage = { current, limit, remaining }; } } return { title: sanitizeText(title), description: sanitizeText(description), tags: tags.map(tag => sanitizeText(tag)).filter(Boolean), suggestedPriceRange: sanitizeText(priceRange), usage }; } // Extract IP from request (for future use with actual network layer) function getRequestIP() { // In stdio mode, we don't have real IP, use a placeholder // This can be enhanced if the server runs in HTTP mode return 'stdio-client'; } async function generateEtsySEO(productName, category = '') { // Input validation validateInput(productName, MAX_PRODUCT_NAME_LENGTH, 'Product name'); if (typeof productName !== 'string' || !productName.trim()) { throw new Error('Product name is required.'); } if (category) { validateInput(category, MAX_CATEGORY_LENGTH, 'Category'); } if (!userEmail || typeof userEmail !== 'string' || !userEmail.trim()) { throw new Error('Email not configured. Please add your email to the MCP config.'); } const normalizedEmail = userEmail.trim(); validateInput(normalizedEmail, MAX_EMAIL_LENGTH, 'Email'); if (!validateEmail(normalizedEmail)) { throw new Error('Invalid email format. Please provide a valid email address.'); } // Check both email-based and IP-based rate limits if (!checkRateLimit(normalizedEmail, false)) { throw new Error(`Rate limit exceeded. Maximum ${RATE_LIMIT_MAX_REQUESTS} requests per minute allowed.`); } const clientIP = getRequestIP(); if (!checkRateLimit(clientIP, true)) { throw new Error(`Rate limit exceeded. Maximum ${RATE_LIMIT_MAX_REQUESTS} requests per minute per client.`); } try { const payload = { product_name: productName.trim(), category: typeof category === 'string' ? category.trim() : '', email: normalizedEmail }; const { signature, timestamp } = generateSignature(payload); const response = await fetch(API_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json', 'User-Agent': 'etsy-seo-mcp/1.0.0', 'X-MCP-Signature': signature, 'X-MCP-Timestamp': timestamp, 'X-MCP-Version': '1.0.0' }, body: JSON.stringify(payload), signal: AbortSignal.timeout(30000) // 30 second timeout }); if (!response.ok) { let errorMessage = 'Failed to generate SEO content. Please try again.'; try { const errorBody = await response.json(); if (errorBody?.error) { // Only expose safe error messages from API const safeErrors = [ 'Rate limit exceeded', 'Invalid product name', 'Invalid category', 'Quota exceeded', 'Invalid email', 'Invalid signature', 'Request expired' ]; const isSafeError = safeErrors.some(safe => errorBody.error.includes(safe)); if (isSafeError) { errorMessage = errorBody.error; } else { log('warn', 'API error sanitized', { original: errorBody.error, status: response.status }); } } } catch (parseError) { log('debug', 'Failed to parse error response', { status: response.status }); } throw new Error(errorMessage); } const data = await response.json(); const validated = validateApiResponse(data); log('info', 'SEO generated successfully', { email: normalizedEmail }); return validated; } catch (error) { log('error', 'Error in generateEtsySEO', error.message); // Sanitize error messages before throwing const sanitizedMessage = error.message || 'An unexpected error occurred. Please try again.'; throw new Error(sanitizedMessage); } } // Validate API endpoint and secret on startup try { validateApiEndpoint(API_ENDPOINT); validateApiSecret(apiSecret); log('info', 'Security validation passed', { environment: NODE_ENV }); } catch (error) { log('error', 'Startup validation failed', error.message); process.exit(1); } // MCP Protocol Handler process.stdin.on('data', async (chunk) => { // Buffer overflow protection if (buffer.length + chunk.length > MAX_BUFFER_SIZE) { log('error', 'Buffer overflow detected', { bufferSize: buffer.length, chunkSize: chunk.length }); buffer = ''; // Reset buffer return; } buffer += chunk; // Process line by line const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { if (!line.trim()) continue; // Length validation for individual lines if (line.length > MAX_INPUT_LENGTH) { log('warn', 'Input line too long, skipping', { length: line.length }); continue; } try { // Safe JSON parsing with error handling let request; try { request = JSON.parse(line); } catch (jsonError) { log('warn', 'Invalid JSON received', { error: jsonError.message }); throw new Error('Invalid JSON format'); } // Validate request structure if (!request || typeof request !== 'object') { throw new Error('Invalid request structure'); } if (request.method === 'initialize') { // Get email from initialization params with validation if (request.params?.initializationOptions?.email) { const email = request.params.initializationOptions.email; if (typeof email === 'string' && email.length <= MAX_EMAIL_LENGTH) { userEmail = email; } else { log('warn', 'Invalid email in initialization params'); } } const response = { jsonrpc: '2.0', id: request.id, result: { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'etsy-seo-mcp', version: '1.0.0' } } }; console.log(JSON.stringify(response)); } else if (request.method === 'tools/list') { // List available tools const response = { jsonrpc: '2.0', id: request.id, result: { tools: [ { name: 'generate_etsy_seo', description: 'Generate SEO-optimized Etsy product title, description, and tags. Free: 10 generations/month.', inputSchema: { type: 'object', properties: { product_name: { type: 'string', description: 'The product name to generate SEO for' }, category: { type: 'string', description: 'Optional product category (e.g., "Home & Living")' } }, required: ['product_name'] } } ] } }; console.log(JSON.stringify(response)); } else if (request.method === 'tools/call') { // Execute tool const { name, arguments: args } = request.params; if (name === 'generate_etsy_seo') { if (!args || typeof args.product_name !== 'string') { throw new Error('Invalid arguments: product_name is required'); } const category = typeof args.category === 'string' ? args.category : ''; const result = await generateEtsySEO(args.product_name, category); const usageInfo = result.usage ? `\n\n---\n**Usage:** ${result.usage.current}/${result.usage.limit} generations used this month (${result.usage.remaining} remaining)` : ''; const sanitizedProductName = sanitizeText(args.product_name); const tagList = result.tags.length ? result.tags.join(', ') : 'No tags provided'; const response = { jsonrpc: '2.0', id: request.id, result: { content: [ { type: 'text', text: `# Etsy SEO Results for "${sanitizedProductName}" ## ๐Ÿ“ SEO Title ${result.title} ## ๐Ÿ“„ Product Description ${result.description} ## ๐Ÿท๏ธ Tags (${result.tags.length}) ${tagList} ## ๐Ÿ’ฐ Suggested Price ${result.suggestedPriceRange}${usageInfo}` } ] } }; console.log(JSON.stringify(response)); } else { throw new Error(`Unknown tool: ${name}`); } } } catch (error) { const errorResponse = { jsonrpc: '2.0', id: null, error: { code: -32603, message: error.message } }; console.log(JSON.stringify(errorResponse)); } } }); process.stdin.on('end', () => { process.exit(0); }); // Handle errors process.on('uncaughtException', (error) => { log('error', 'Uncaught exception', error.message); process.exit(1); }); log('info', 'Etsy SEO MCP Server started', { version: '1.0.0', logLevel: LOG_LEVEL, environment: NODE_ENV });

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/semihbugrasezer/etsy-seo-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server