Skip to main content
Glama
index.js28.2 kB
#!/usr/bin/env node import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; import FormData from 'form-data'; import fetch from 'node-fetch'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; import dotenv from 'dotenv'; // Load environment variables dotenv.config(); const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // Logging configuration const DEBUG = process.env.DEBUG === 'true'; const LOG_LEVELS = ['error', 'warn', 'info', 'debug']; function log(level, message, data) { if (level === 'debug' && !DEBUG) return; const timestamp = new Date().toISOString(); const logData = data ? ` ${JSON.stringify(data)}` : ''; console.error(`[${timestamp}] [${level.toUpperCase()}] ${message}${logData}`); } // Validate required environment variables const REQUIRED_ENV_VARS = ['MARKUPAI_API_KEY']; const missingVars = REQUIRED_ENV_VARS.filter(varName => !process.env[varName]); if (missingVars.length > 0) { log('error', 'Missing required environment variables', missingVars); console.error('\nPlease set the following environment variables:'); missingVars.forEach(varName => console.error(` - ${varName}`)); console.error('\nYou can create a .env file with these variables.'); process.exit(1); } // Configuration const MARKUPAI_BASE_URL = process.env.MARKUPAI_BASE_URL || 'https://api.markup.ai'; const MARKUPAI_API_KEY = process.env.MARKUPAI_API_KEY; const WORKFLOW_TIMEOUT = parseInt(process.env.WORKFLOW_TIMEOUT || '60000', 10); const POLL_INTERVAL = parseInt(process.env.POLL_INTERVAL || '2000', 10); const MAX_RETRIES = parseInt(process.env.MAX_RETRIES || '3', 10); log('info', 'Configuration loaded', { baseUrl: MARKUPAI_BASE_URL, workflowTimeout: WORKFLOW_TIMEOUT, pollInterval: POLL_INTERVAL, maxRetries: MAX_RETRIES }); // Style guide name to UUID mapping const STYLE_GUIDE_IDS = { 'ap': '01971e03-dd27-75ee-9044-b48e654848cf', 'chicago': '01971e03-dd27-77d8-a6fa-5edb6a1f4ad2', 'microsoft': '01971e03-dd27-779f-b3ec-b724a2cf809f', 'proofpoint': '01971e03-dd27-7dfa-8d96-d48c8cf5e4fe', }; // Type guards function isBaseToolArgs(args) { return (typeof args === 'object' && args !== null && 'text' in args && typeof args.text === 'string'); } function isWorkflowStatusArgs(args) { return (typeof args === 'object' && args !== null && 'workflow_id' in args && 'workflow_type' in args && typeof args.workflow_id === 'string' && ['rewrites', 'checks', 'suggestions'].includes(args.workflow_type)); } // Input validation function validateTextInput(text) { if (!text || text.trim().length === 0) { throw new Error('Text parameter is required and must be a non-empty string'); } const MAX_TEXT_LENGTH = parseInt(process.env.MAX_TEXT_LENGTH || '100000', 10); if (text.length > MAX_TEXT_LENGTH) { throw new Error(`Text exceeds maximum length of ${MAX_TEXT_LENGTH} characters`); } } // Retry logic with exponential backoff async function retryWithBackoff(fn, operationName, maxRetries = MAX_RETRIES, baseDelay = 1000) { for (let i = 0; i < maxRetries; i++) { try { log('debug', `Attempting ${operationName} (attempt ${i + 1}/${maxRetries})`); const result = await fn(); if (i > 0) { log('info', `${operationName} succeeded after ${i + 1} attempts`); } return result; } catch (error) { const isLastAttempt = i === maxRetries - 1; const delay = baseDelay * Math.pow(2, i); log('warn', `${operationName} failed (attempt ${i + 1}/${maxRetries})`, { error: error instanceof Error ? error.message : error, willRetry: !isLastAttempt, nextDelayMs: !isLastAttempt ? delay : undefined }); if (isLastAttempt) { throw new Error(`${operationName} failed after ${maxRetries} attempts: ${error instanceof Error ? error.message : error}`); } await new Promise(resolve => setTimeout(resolve, delay)); } } throw new Error(`${operationName}: Max retries exceeded`); } // Define available tools const TOOLS = [ { name: 'markupai_rewrite', description: 'Automatically rewrite and improve text content using AI-powered style guides. This tool analyzes your text for grammar, clarity, tone, and style guide compliance, then provides a completely rewritten version. Use this when you need to transform rough drafts into polished content, ensure consistency with brand voice, or adapt content for different audiences. Returns both before/after scores and the rewritten text.', inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'The text content to rewrite', minLength: 1, }, dialect: { type: 'string', description: 'Language dialect', enum: ['american_english', 'british_oxford', 'canadian_english'], default: 'american_english' }, tone: { type: 'string', description: 'Desired tone of the rewritten content', enum: ['academic', 'business', 'casual', 'conversational', 'formal', 'gen-z', 'informal', 'technical'], default: 'formal' }, style_guide: { type: 'string', description: 'Style guide to follow (predefined: ap, chicago, microsoft, proofpoint) or custom UUID', default: 'microsoft' } }, required: ['text'] } }, { name: 'markupai_check', description: 'Analyze text for quality issues without making changes. This tool provides detailed scores for grammar, clarity, tone, style guide compliance, and terminology. Use this for content audits, quality assessments, or when you want to understand specific issues before editing. Returns comprehensive readability metrics and issue counts by category.', inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'The text content to analyze', minLength: 1, }, dialect: { type: 'string', description: 'Language dialect', enum: ['american_english', 'british_oxford', 'canadian_english'], default: 'american_english' }, tone: { type: 'string', description: 'Target tone to check against', enum: ['academic', 'business', 'casual', 'conversational', 'formal', 'gen-z', 'informal', 'technical'], default: 'formal' }, style_guide: { type: 'string', description: 'Style guide to check against (predefined: ap, chicago, microsoft) or custom UUID', default: 'microsoft' } }, required: ['text'] } }, { name: 'markupai_suggestions', description: 'Get detailed editing suggestions for improving text. This tool identifies specific issues and provides targeted recommendations for each problem found. Use this when you want to maintain editorial control while getting guidance on improvements. Returns a categorized list of issues with specific suggestions for each.', inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'The text content to get suggestions for', minLength: 1, }, dialect: { type: 'string', description: 'Language dialect', enum: ['american_english', 'british_oxford', 'canadian_english'], default: 'american_english' }, tone: { type: 'string', description: 'Target tone for suggestions', enum: ['academic', 'business', 'casual', 'conversational', 'formal', 'gen-z', 'informal', 'technical'], default: 'formal' }, style_guide: { type: 'string', description: 'Style guide for suggestions (predefined: ap, chicago, microsoft) or custom UUID', default: 'microsoft' } }, required: ['text'] } }, { name: 'markupai_workflow_status', description: 'Check the status of an asynchronous Markup.ai workflow. Use this to poll for results when other operations return a running status. Workflows typically complete within 5-30 seconds depending on text length and complexity.', inputSchema: { type: 'object', properties: { workflow_id: { type: 'string', description: 'The workflow ID to check status for' }, workflow_type: { type: 'string', description: 'The type of workflow', enum: ['rewrites', 'checks', 'suggestions'] } }, required: ['workflow_id', 'workflow_type'] } } ]; // Create a buffer for upload function createTextBuffer(text) { return Buffer.from(text, 'utf-8'); } // Submit a workflow to Markup.ai async function submitWorkflow(endpoint, text, dialect, tone, style_guide) { validateTextInput(text); return retryWithBackoff(async () => { const formData = new FormData(); const textBuffer = createTextBuffer(text); formData.append('file_upload', textBuffer, { filename: 'content.txt', contentType: 'text/plain' }); formData.append('dialect', dialect); formData.append('tone', tone); // Convert style guide name to UUID const styleGuideId = STYLE_GUIDE_IDS[style_guide] || style_guide; formData.append('style_guide', styleGuideId); log('debug', `Submitting ${endpoint} workflow`, { dialect, tone, style_guide: styleGuideId, textLength: text.length }); const response = await fetch(`${MARKUPAI_BASE_URL}/v1/style/${endpoint}`, { method: 'POST', headers: { 'Authorization': `Bearer ${MARKUPAI_API_KEY}`, ...formData.getHeaders() }, body: formData, }); if (!response.ok) { const errorText = await response.text(); log('error', `API request failed`, { status: response.status, statusText: response.statusText, error: errorText }); throw new Error(`Markup.ai API error: ${response.status} ${response.statusText} - ${errorText}`); } const result = await response.json(); log('info', `Workflow submitted successfully`, { workflow_id: result.workflow_id, status: result.status }); return result; }, `submit ${endpoint} workflow`); } // Get workflow status async function getWorkflowStatus(workflow_id, workflow_type) { return retryWithBackoff(async () => { log('debug', `Checking workflow status`, { workflow_id, workflow_type }); const response = await fetch(`${MARKUPAI_BASE_URL}/v1/style/${workflow_type}/${workflow_id}`, { headers: { 'Authorization': `Bearer ${MARKUPAI_API_KEY}`, }, }); if (!response.ok) { const errorText = await response.text(); log('error', `Status check failed`, { status: response.status, statusText: response.statusText, error: errorText }); throw new Error(`Markup.ai API error: ${response.status} ${response.statusText} - ${errorText}`); } const result = await response.json(); log('debug', `Workflow status retrieved`, { workflow_id, status: result.status }); return result; }, 'check workflow status'); } // Poll workflow until completion with timeout async function pollWorkflowCompletion(workflow_id, workflow_type, initialResult) { const startTime = Date.now(); let result = initialResult; let status = result.status; log('info', `Polling workflow for completion`, { workflow_id, workflow_type, timeout: WORKFLOW_TIMEOUT }); while (status === 'running') { const elapsedTime = Date.now() - startTime; if (elapsedTime > WORKFLOW_TIMEOUT) { log('error', `Workflow timeout`, { workflow_id, elapsedTime, timeout: WORKFLOW_TIMEOUT }); throw new Error(`Workflow timeout after ${WORKFLOW_TIMEOUT}ms. Workflow ID: ${workflow_id}`); } await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL)); try { result = await getWorkflowStatus(workflow_id, workflow_type); status = result.status; log('debug', `Workflow poll update`, { workflow_id, status, elapsedTime }); } catch (error) { log('warn', `Error during polling, will retry`, { workflow_id, error: error instanceof Error ? error.message : error }); // Continue polling even if individual status checks fail } } if (status === 'failed') { log('error', `Workflow failed`, { workflow_id, error: result.error }); throw new Error(`Workflow failed: ${result.error || 'Unknown error'}`); } log('info', `Workflow completed successfully`, { workflow_id, totalTime: Date.now() - startTime }); // Ensure the final result includes the workflow_id from the original submission if (!result.workflow_id) { result.workflow_id = workflow_id; } return result; } // Format response with highlighted scores function formatResponse(result) { let formatted = `Status: ${result.status}\n`; if (result.workflow_id) { formatted += `Workflow ID: ${result.workflow_id}\n`; } // Format original scores if (result.scores) { formatted += '\n=== SCORES ===\n'; // Quality scores if (result.scores.quality) { formatted += `Quality Score: ${result.scores.quality.score ?? 'N/A'}\n`; if (result.scores.quality.grammar) { formatted += ` Grammar: ${result.scores.quality.grammar.score ?? 'N/A'}`; if (result.scores.quality.grammar.issues !== undefined) { formatted += ` (${result.scores.quality.grammar.issues} issues)`; } formatted += '\n'; } if (result.scores.quality.style_guide) { formatted += ` Style Guide: ${result.scores.quality.style_guide.score ?? 'N/A'}`; if (result.scores.quality.style_guide.issues !== undefined) { formatted += ` (${result.scores.quality.style_guide.issues} issues)`; } formatted += '\n'; } if (result.scores.quality.terminology) { formatted += ` Terminology: ${result.scores.quality.terminology.score ?? 'N/A'}`; if (result.scores.quality.terminology.issues !== undefined) { formatted += ` (${result.scores.quality.terminology.issues} issues)`; } formatted += '\n'; } } // Analysis scores if (result.scores.analysis) { if (result.scores.analysis.clarity) { const clarity = result.scores.analysis.clarity; formatted += `Clarity Score: ${clarity.score ?? 'N/A'}\n`; if (clarity.word_count !== undefined) { formatted += ` - Word Count: ${clarity.word_count}\n`; } if (clarity.sentence_count !== undefined) { formatted += ` - Sentence Count: ${clarity.sentence_count}\n`; } if (clarity.average_sentence_length !== undefined) { formatted += ` - Avg Sentence Length: ${clarity.average_sentence_length}\n`; } if (clarity.flesch_reading_ease !== undefined) { formatted += ` - Flesch Reading Ease: ${clarity.flesch_reading_ease}\n`; } if (clarity.flesch_kincaid_grade !== undefined) { formatted += ` - Flesch-Kincaid Grade: ${clarity.flesch_kincaid_grade}\n`; } } if (result.scores.analysis.tone) { const tone = result.scores.analysis.tone; formatted += `Tone Score: ${tone.score ?? 'N/A'}\n`; if (tone.informality !== undefined && tone.target_informality !== undefined) { formatted += ` - Informality: ${tone.informality} (target: ${tone.target_informality})\n`; } if (tone.liveliness !== undefined && tone.target_liveliness !== undefined) { formatted += ` - Liveliness: ${tone.liveliness} (target: ${tone.target_liveliness})\n`; } } } } // Format rewrite scores (for RewriteResponse) if ('rewrite_scores' in result && result.rewrite_scores) { formatted += '\n=== REWRITE SCORES ===\n'; // Quality scores if (result.rewrite_scores.quality) { formatted += `Quality Score: ${result.rewrite_scores.quality.score ?? 'N/A'}\n`; if (result.rewrite_scores.quality.grammar) { formatted += ` Grammar: ${result.rewrite_scores.quality.grammar.score ?? 'N/A'}`; if (result.rewrite_scores.quality.grammar.issues !== undefined) { formatted += ` (${result.rewrite_scores.quality.grammar.issues} issues)`; } formatted += '\n'; } if (result.rewrite_scores.quality.style_guide) { formatted += ` Style Guide: ${result.rewrite_scores.quality.style_guide.score ?? 'N/A'}`; if (result.rewrite_scores.quality.style_guide.issues !== undefined) { formatted += ` (${result.rewrite_scores.quality.style_guide.issues} issues)`; } formatted += '\n'; } if (result.rewrite_scores.quality.terminology) { formatted += ` Terminology: ${result.rewrite_scores.quality.terminology.score ?? 'N/A'}`; if (result.rewrite_scores.quality.terminology.issues !== undefined) { formatted += ` (${result.rewrite_scores.quality.terminology.issues} issues)`; } formatted += '\n'; } } // Analysis scores if (result.rewrite_scores.analysis) { if (result.rewrite_scores.analysis.clarity) { formatted += `Clarity Score: ${result.rewrite_scores.analysis.clarity.score ?? 'N/A'}\n`; } if (result.rewrite_scores.analysis.tone) { formatted += `Tone Score: ${result.rewrite_scores.analysis.tone.score ?? 'N/A'}\n`; } } } // Format rewritten text (for RewriteResponse) if ('rewrite' in result && result.rewrite) { formatted += '\n=== REWRITTEN TEXT ===\n'; formatted += result.rewrite + '\n'; } // Format issues/suggestions if (result.issues && result.issues.length > 0) { formatted += `\n=== ISSUES (${result.issues.length} total) ===\n`; result.issues.forEach((issue, idx) => { const suggestion = 'suggestion' in issue ? issue.suggestion : undefined; const modified = 'modified' in issue ? issue.modified : undefined; formatted += `${idx + 1}. [${issue.category || issue.subcategory || 'N/A'}] ${issue.original} → ${suggestion || modified || 'N/A'}\n`; }); } if (DEBUG) { formatted += '\n=== FULL RESPONSE ===\n'; formatted += JSON.stringify(result, null, 2); } return formatted; } // Graceful shutdown handler async function gracefulShutdown(server) { log('info', 'Received shutdown signal, closing server gracefully...'); try { await server.close(); log('info', 'Server closed successfully'); process.exit(0); } catch (error) { log('error', 'Error during shutdown', error); process.exit(1); } } // Main server async function main() { const server = new Server({ name: 'markupai-mcp-server', vendor: 'markupai', version: '1.0.0', description: 'MCP server for Markup.ai API text analysis and improvement' }, { capabilities: { tools: {}, }, }); // Register shutdown handlers process.on('SIGTERM', () => gracefulShutdown(server)); process.on('SIGINT', () => gracefulShutdown(server)); // Handle list tools request server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: TOOLS, }; }); // Handle tool calls server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { switch (name) { case 'markupai_rewrite': { if (!isBaseToolArgs(args)) { throw new Error('Invalid arguments for markupai_rewrite'); } const { text, dialect = 'american_english', tone = 'formal', style_guide = 'microsoft' } = args; log('info', `Starting rewrite operation`, { textLength: text.length, dialect, tone, style_guide }); // Submit rewrite request const submitResult = await submitWorkflow('rewrites', text, dialect, tone, style_guide); // Poll for completion if workflow is running, otherwise use the immediate result const result = submitResult.status === 'running' && submitResult.workflow_id ? await pollWorkflowCompletion(submitResult.workflow_id, 'rewrites', submitResult) : submitResult; return { content: [ { type: 'text', text: formatResponse(result) } ] }; } case 'markupai_check': { if (!isBaseToolArgs(args)) { throw new Error('Invalid arguments for markupai_check'); } const { text, dialect = 'american_english', tone = 'formal', style_guide = 'microsoft' } = args; log('info', `Starting check operation`, { textLength: text.length, dialect, tone, style_guide }); // Submit check request const submitResult = await submitWorkflow('checks', text, dialect, tone, style_guide); // Poll for completion if workflow is running, otherwise use the immediate result const result = submitResult.status === 'running' && submitResult.workflow_id ? await pollWorkflowCompletion(submitResult.workflow_id, 'checks', submitResult) : submitResult; return { content: [ { type: 'text', text: formatResponse(result) } ] }; } case 'markupai_suggestions': { if (!isBaseToolArgs(args)) { throw new Error('Invalid arguments for markupai_suggestions'); } const { text, dialect = 'american_english', tone = 'formal', style_guide = 'microsoft' } = args; log('info', `Starting suggestions operation`, { textLength: text.length, dialect, tone, style_guide }); // Submit suggestions request const submitResult = await submitWorkflow('suggestions', text, dialect, tone, style_guide); // Poll for completion if workflow is running, otherwise use the immediate result const result = submitResult.status === 'running' && submitResult.workflow_id ? await pollWorkflowCompletion(submitResult.workflow_id, 'suggestions', submitResult) : submitResult; return { content: [ { type: 'text', text: formatResponse(result) } ] }; } case 'markupai_workflow_status': { if (!isWorkflowStatusArgs(args)) { throw new Error('Invalid arguments for markupai_workflow_status'); } const { workflow_id, workflow_type } = args; log('info', `Checking workflow status`, { workflow_id, workflow_type }); const result = await getWorkflowStatus(workflow_id, workflow_type); return { content: [ { type: 'text', text: formatResponse(result) } ] }; } default: throw new Error(`Unknown tool: ${name}`); } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('error', `Tool execution failed`, { tool: name, error: errorMessage }); return { content: [ { type: 'text', text: `Error: ${errorMessage}` } ], isError: true }; } }); const transport = new StdioServerTransport(); await server.connect(transport); log('info', 'Markup.ai MCP server running on stdio'); } main().catch((error) => { log('error', 'Server startup failed', error); console.error('Fatal error:', error); process.exit(1); });

Latest Blog Posts

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/acrolinx/nextgen-mcp'

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