marketing-post-generator-mcp
Version:
A powerful MCP server for AI-powered marketing blog post generation with Claude integration
504 lines (480 loc) • 21.2 kB
JavaScript
import { createLogger } from '../utils/logger.js';
import * as fs from 'fs/promises';
import * as path from 'path';
export class BlogPostGeneratorTool {
logger;
constructor(_searchService, logger) {
this.logger = logger || createLogger({ level: 'info', format: 'simple' });
}
getToolDefinition() {
return {
name: 'marketing_post_generator_mcp__write_post',
description: 'Write a complete blog post from scratch or based on a narrative. Generates full blog posts with configurable style, word count, and optional narrative foundation.',
inputSchema: {
type: 'object',
properties: {
narrativeId: {
type: 'string',
description: 'ID of the narrative to base the post on (optional)',
},
title: {
type: 'string',
description: 'Title of the blog post (required if narrativeId not provided)',
},
topic: {
type: 'string',
description: 'Topic of the blog post (required if narrativeId not provided)',
},
keywords: {
type: 'array',
items: {
type: 'string',
},
description: 'Keywords for the blog post (optional)',
},
wordCount: {
type: 'number',
description: 'Target word count for the post',
default: 1000,
minimum: 500,
maximum: 5000,
},
style: {
type: 'string',
description: 'Writing style for the post',
enum: ['informative', 'persuasive', 'storytelling', 'technical', 'conversational'],
default: 'informative',
},
updateExisting: {
type: 'boolean',
description: 'Whether to update an existing post or create a new one',
default: false,
},
},
required: [],
},
};
}
async execute(args, claudeService) {
const { narrativeId, title, topic, keywords = [], wordCount = 1000, style = 'informative', updateExisting = false, } = args;
try {
this.logger.info('Starting blog post generation tool execution', {
narrativeId,
title,
topic,
wordCount,
style,
updateExisting,
});
// Validate .postgen directory exists
await this.validatePostgenDirectory();
// Get configuration
const config = await this.getPostgenConfig();
// Determine title and topic
let finalTitle = title;
let finalTopic = topic;
let finalKeywords = keywords;
let narrative = null;
// If narrativeId is provided, get the narrative
if (narrativeId) {
narrative = await this.getNarrative(narrativeId);
finalTitle = narrative.title;
finalTopic = narrative.narrative.keyPoints[0] || topic || 'General topic';
// Extract keywords from narrative if not provided
if (keywords.length === 0) {
finalKeywords = this.extractKeywordsFromNarrative(narrative);
}
}
else if (!finalTitle || !finalTopic) {
throw new Error('Either narrativeId or both title and topic must be provided.');
}
// Get tone analysis for the domain
const toneAnalysis = await this.getExistingToneAnalysis(config.domain);
this.logger.info('Data gathering completed', {
finalTitle,
finalTopic,
keywordCount: finalKeywords.length,
hasNarrative: !!narrative,
hasToneAnalysis: !!toneAnalysis,
});
// Generate blog post with Claude
const blogPostContent = await this.generateBlogPost({
title: finalTitle,
topic: finalTopic,
keywords: finalKeywords,
wordCount,
style,
toneAnalysis,
narrative,
domain: config.domain,
claudeService,
});
// Save the blog post
const result = await this.saveBlogPost({
title: finalTitle,
topic: finalTopic,
keywords: finalKeywords,
wordCount,
style,
narrativeId: narrativeId || null,
content: blogPostContent,
domain: config.domain,
});
this.logger.info('Blog post generation tool execution completed successfully', {
postId: result.id,
title: finalTitle,
actualWordCount: this.estimateWordCount(blogPostContent),
filePath: result.filePath,
});
return this.formatResponse(result);
}
catch (error) {
this.logger.error('Blog post generation tool execution failed', {
narrativeId,
title,
error: error instanceof Error ? error.message : String(error),
});
throw new Error(`Failed to generate blog post: ${error instanceof Error ? error.message : String(error)}`);
}
}
async validatePostgenDirectory() {
const postgenDir = path.join(process.cwd(), '.postgen');
try {
await fs.access(postgenDir);
}
catch (error) {
throw new Error('The .postgen directory does not exist. Please run the init tool first to set up the project structure.');
}
// Ensure posts directory exists
const postsDir = path.join(postgenDir, 'posts');
try {
await fs.access(postsDir);
}
catch (error) {
await fs.mkdir(postsDir, { recursive: true });
this.logger.info('Created posts directory', { postsDir });
}
}
async getPostgenConfig() {
try {
const configPath = path.join(process.cwd(), '.postgen', 'config.json');
const configData = await fs.readFile(configPath, 'utf-8');
return JSON.parse(configData);
}
catch (error) {
throw new Error('Failed to load .postgen configuration. Please run the init tool first.');
}
}
async getNarrative(narrativeId) {
try {
const narrativesDir = path.join(process.cwd(), '.postgen', 'narratives');
const files = await fs.readdir(narrativesDir);
// Find narrative files that match the narrativeId
const narrativeFiles = files.filter((file) => file.startsWith(`${narrativeId}-`) && file.endsWith('.json'));
if (narrativeFiles.length === 0) {
throw new Error(`No narrative found with ID "${narrativeId}". Available narratives: ${files
.filter((f) => f.endsWith('.json'))
.map((f) => f.split('-')[0])
.join(', ')}`);
}
// Get the most recent narrative file
const filesWithStats = await Promise.all(narrativeFiles.map(async (file) => {
const stat = await fs.stat(path.join(narrativesDir, file));
return { file, mtime: stat.mtime };
}));
const latestFile = filesWithStats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime())[0];
if (!latestFile) {
throw new Error(`No valid narrative files found for ID "${narrativeId}"`);
}
const content = await fs.readFile(path.join(narrativesDir, latestFile.file), 'utf-8');
return JSON.parse(content);
}
catch (error) {
if (error instanceof Error && error.message.includes('No narrative found')) {
throw error;
}
throw new Error(`Failed to load narrative "${narrativeId}": ${error}`);
}
}
extractKeywordsFromNarrative(narrative) {
const keywords = new Set();
// Extract keywords from title
narrative.title
.toLowerCase()
.split(/\s+/)
.filter((word) => word.length > 3)
.forEach((word) => keywords.add(word));
// Extract keywords from key points
narrative.narrative.keyPoints.forEach((point) => {
point
.toLowerCase()
.split(/\s+/)
.filter((word) => word.length > 3 && !['this', 'that', 'with', 'from'].includes(word))
.forEach((word) => keywords.add(word));
});
return Array.from(keywords).slice(0, 5); // Limit to 5 keywords
}
async getExistingToneAnalysis(domain) {
try {
const toneAnalysisDir = path.join(process.cwd(), '.postgen', 'analysis', 'tone-analysis');
const files = await fs.readdir(toneAnalysisDir);
// Look for domain-related tone analysis files
const domainMatcher = this.createDomainMatcher(domain);
const domainFiles = [];
for (const file of files) {
try {
const content = await fs.readFile(path.join(toneAnalysisDir, file), 'utf-8');
const toneData = JSON.parse(content);
if (toneData.source && domainMatcher(file)) {
domainFiles.push(file);
}
}
catch {
// Skip files that can't be read or parsed
}
}
if (domainFiles.length > 0) {
// Use the most recent tone analysis
const filesWithStats = await Promise.all(domainFiles.map(async (file) => {
const stat = await fs.stat(path.join(toneAnalysisDir, file));
return { file, mtime: stat.mtime };
}));
const latestFile = filesWithStats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime())[0];
if (latestFile) {
const content = await fs.readFile(path.join(toneAnalysisDir, latestFile.file), 'utf-8');
const toneData = JSON.parse(content);
return {
formality: toneData.toneAnalysis.formality || 'Not specified',
emotion: toneData.toneAnalysis.emotion || 'Not specified',
style: toneData.toneAnalysis.style || 'Not specified',
overallTone: toneData.toneAnalysis.overallTone || 'Not specified',
wordChoicePatterns: toneData.toneAnalysis.wordChoicePatterns,
sentenceStructure: toneData.toneAnalysis.sentenceStructure,
uniqueCharacteristics: toneData.toneAnalysis.uniqueCharacteristics,
};
}
}
return null;
}
catch (error) {
this.logger.warn('No existing tone analysis found for domain', { domain, error });
return null;
}
}
createDomainMatcher(domain) {
const normalizedDomain = domain.replace(/^www\./, '').toLowerCase();
return (filename) => {
const normalizedFile = filename.toLowerCase();
return ((normalizedFile.includes(normalizedDomain.replace(/\./g, '-')) ||
normalizedFile.includes(normalizedDomain)) &&
filename.endsWith('.json'));
};
}
async generateBlogPost(options) {
const prompt = this.buildBlogPostPrompt(options);
this.logger.info('Generating blog post with Claude', {
title: options.title,
style: options.style,
targetWordCount: options.wordCount,
promptLength: prompt.length,
});
const response = await options.claudeService.generateContent(prompt, {
maxTokens: Math.min(8192, Math.max(2048, options.wordCount * 2)),
temperature: options.style === 'storytelling' ? 0.7 : options.style === 'conversational' ? 0.6 : 0.5,
});
return response.content;
}
buildBlogPostPrompt(options) {
const { title, topic, keywords, wordCount, style, toneAnalysis, narrative, domain } = options;
// Style-specific guidelines
const styleGuidelines = {
informative: {
approach: 'Provide comprehensive, well-researched information with clear explanations',
structure: 'Clear introduction, logical sections with headers, conclusion with summary',
tone: 'Professional, authoritative, educational',
},
persuasive: {
approach: 'Present compelling arguments and evidence to convince readers',
structure: 'Hook introduction, problem/solution format, strong call-to-action',
tone: 'Confident, compelling, action-oriented',
},
storytelling: {
approach: 'Use narrative techniques to engage and entertain while informing',
structure: 'Engaging story arc with character development and resolution',
tone: 'Engaging, emotional, relatable',
},
technical: {
approach: 'Provide detailed technical information with precise explanations',
structure: 'Technical overview, step-by-step explanations, implementation details',
tone: 'Precise, analytical, expert-level',
},
conversational: {
approach: 'Write in a friendly, approachable manner as if talking to a friend',
structure: 'Casual introduction, flowing sections, personal insights',
tone: 'Friendly, approachable, personal',
},
};
const guidelines = styleGuidelines[style] || styleGuidelines.informative;
let prompt = `You are a professional content writer creating a complete blog post.
## Post Requirements
- **Title**: ${title}
- **Topic**: ${topic}
- **Target Word Count**: ${wordCount} words
- **Writing Style**: ${style}
- **Domain**: ${domain}
## Style Guidelines
- **Approach**: ${guidelines.approach}
- **Structure**: ${guidelines.structure}
- **Tone**: ${guidelines.tone}`;
// Add keywords if provided
if (keywords.length > 0) {
prompt += `
- **Keywords to Include**: ${keywords.join(', ')}`;
}
// Add tone analysis if available
if (toneAnalysis) {
prompt += `
## Brand Voice Guidelines
Maintain the following tone characteristics throughout the post:
- **Formality Level**: ${toneAnalysis.formality}
- **Emotional Tone**: ${toneAnalysis.emotion}
- **Writing Style**: ${toneAnalysis.style}
- **Overall Tone**: ${toneAnalysis.overallTone}`;
if (toneAnalysis.wordChoicePatterns) {
prompt += `
- **Word Choice Patterns**: ${toneAnalysis.wordChoicePatterns}`;
}
if (toneAnalysis.sentenceStructure) {
prompt += `
- **Sentence Structure**: ${toneAnalysis.sentenceStructure}`;
}
}
// Add narrative context if provided
if (narrative) {
prompt += `
## Narrative Foundation
Use this narrative structure as the foundation for your blog post:
### Introduction
${narrative.narrative.introduction}
### Main Sections
${narrative.narrative.mainSections
.map((section, index) => `#### ${index + 1}. ${section.title}\n${section.content}`)
.join('\n\n')}
### Conclusion
${narrative.narrative.conclusion}
### Key Points to Include
${narrative.narrative.keyPoints.map((point) => `- ${point}`).join('\n')}
### Call to Action
${narrative.narrative.callToAction}`;
}
prompt += `
## Content Requirements
Please write a complete blog post that:
1. **Engaging Introduction**: Hook the reader and clearly establish the topic
2. **Well-Structured Body**: Organize content into logical sections with clear headers
3. **Supporting Content**: Include examples, explanations, and relevant details
4. **Natural Keyword Integration**: Incorporate target keywords naturally throughout
5. **Strong Conclusion**: Summarize key points and provide clear next steps
6. **SEO Optimization**: Use headers (H2, H3) and maintain good readability
## Output Format
Please provide the complete blog post in markdown format with:
- Proper heading hierarchy (H1 for title, H2 for main sections, H3 for subsections)
- Well-formatted paragraphs and lists
- Natural keyword integration
- Engaging and readable content
The final post should be approximately ${wordCount} words and perfectly embody the ${style} writing style while maintaining brand consistency.
Generate the complete blog post now:`;
return prompt;
}
async saveBlogPost(options) {
const postId = Date.now().toString();
const timestamp = new Date().toISOString();
const postsDir = path.join(process.cwd(), '.postgen', 'posts');
// Create frontmatter
const frontmatter = `---
title: ${options.title}
topic: ${options.topic}
keywords: ${options.keywords.join(', ')}
wordCount: ${options.wordCount}
style: ${options.style}
createdAt: ${timestamp}
narrativeId: ${options.narrativeId}
domain: ${options.domain}
---
`;
// Save markdown file
const postPath = path.join(postsDir, `${postId}.md`);
await fs.writeFile(postPath, frontmatter + options.content);
// Save metadata separately
const metadata = {
id: postId,
title: options.title,
topic: options.topic,
keywords: options.keywords,
wordCount: options.wordCount,
style: options.style,
narrativeId: options.narrativeId,
createdAt: timestamp,
domain: options.domain,
};
const metaPath = path.join(postsDir, `${postId}.json`);
await fs.writeFile(metaPath, JSON.stringify(metadata, null, 2));
// Create excerpt
const contentWithoutFrontmatter = options.content.replace(/^---[\s\S]*?---\s*/, '');
const excerpt = contentWithoutFrontmatter.substring(0, 200).replace(/\n/g, ' ').trim() + '...';
this.logger.info('Blog post saved successfully', {
postId,
title: options.title,
filePath: postPath,
metadataPath: metaPath,
});
return {
id: postId,
title: options.title,
topic: options.topic,
keywords: options.keywords,
wordCount: this.estimateWordCount(options.content),
style: options.style,
narrativeId: options.narrativeId,
createdAt: timestamp,
excerpt,
filePath: postPath,
metadataPath: metaPath,
};
}
estimateWordCount(content) {
// Remove frontmatter and markdown formatting for accurate count
const cleanContent = content
.replace(/^---[\s\S]*?---\s*/, '') // Remove frontmatter
.replace(/#{1,6}\s+/g, '') // Remove headers
.replace(/\*\*(.+?)\*\*/g, '$1') // Remove bold
.replace(/\*(.+?)\*/g, '$1') // Remove italic
.replace(/\[(.+?)\]\(.+?\)/g, '$1') // Remove links
.replace(/`(.+?)`/g, '$1'); // Remove code
return cleanContent.split(/\s+/).filter((word) => word.length > 0).length;
}
formatResponse(result) {
const response = `# Blog Post Generated Successfully
## Post Information
- **ID**: ${result.id}
- **Title**: ${result.title}
- **Topic**: ${result.topic}
- **Style**: ${result.style.charAt(0).toUpperCase() + result.style.slice(1)}
- **Word Count**: ${result.wordCount} words
- **Created**: ${new Date(result.createdAt).toLocaleDateString()}
${result.narrativeId ? `- **Based on Narrative**: ${result.narrativeId}` : ''}
## Content Preview
${result.excerpt}
## Keywords Included
${result.keywords.map((keyword) => `- ${keyword}`).join('\n')}
## Files Created
- **Blog Post**: \`${result.filePath}\`
- **Metadata**: \`${result.metadataPath}\`
---
**Success**: Your blog post has been generated and saved successfully!
**Next Steps**: Review the generated content and make any necessary edits before publishing.
The post is stored in the .postgen/posts directory with both markdown content and JSON metadata for easy management and retrieval.`;
return response;
}
}
//# sourceMappingURL=BlogPostGeneratorTool.js.map