UNPKG

post-maker-mcp

Version:

MCP server for creating posts on X and LinkedIn

289 lines (247 loc) • 9.35 kB
import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, ListToolsResultSchema, CallToolResultSchema, } from "@modelcontextprotocol/sdk/types.js"; interface PostData { text?: string; image?: string; video?: string; hashtags?: string[]; } interface ValidationResult { isValid: boolean; errors: string[]; missingRequired: string[]; } export class PostMakerMCPServer { private server: Server; private serverName: string; constructor(serverName?: string) { this.serverName = serverName || "post-maker-mcp"; this.server = new Server( { name: this.serverName, version: "1.0.0", } ); this.setupToolHandlers(); } private setupToolHandlers(): void { // List available tools this.server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "create_x_post", description: "Create a post for X (Twitter) with text, image, or video. Returns a shareable link with pre-filled content.", inputSchema: { type: "object", properties: { text: { type: "string", description: "Text content for the post (optional if image/video provided)" }, image: { type: "string", description: "Image URL or base64 data (optional if text/video provided)" }, video: { type: "string", description: "Video URL or base64 data (optional if text/image provided)" }, hashtags: { type: "array", items: { type: "string" }, description: "Array of hashtags without # symbol" } }, required: [] } }, { name: "create_linkedin_post", description: "Create a post for LinkedIn with text, image, or video. Returns a shareable link with pre-filled content.", inputSchema: { type: "object", properties: { text: { type: "string", description: "Text content for the post (optional if image/video provided)" }, image: { type: "string", description: "Image URL or base64 data (optional if text/video provided)" }, video: { type: "string", description: "Video URL or base64 data (optional if text/image provided)" }, hashtags: { type: "array", items: { type: "string" }, description: "Array of hashtags without # symbol" } }, required: [] } } ], }; }); // Handle X post creation this.server.setRequestHandler(CallToolRequestSchema, async (request) => { if (request.params.name === "create_x_post") { return this.handleXPost(request.params.arguments as PostData); } else if (request.params.name === "create_linkedin_post") { return this.handleLinkedInPost(request.params.arguments as PostData); } throw new Error(`Unknown tool: ${request.params.name}`); }); } private validatePostData(data: PostData): ValidationResult { const errors: string[] = []; const missingRequired: string[] = []; // Check if at least one required field is provided const hasText = data.text && data.text.trim().length > 0; const hasImage = data.image && data.image.trim().length > 0; const hasVideo = data.video && data.video.trim().length > 0; if (!hasText && !hasImage && !hasVideo) { missingRequired.push("At least one of: text, image, or video"); errors.push("At least one content type (text, image, or video) is required"); } // Validate text length for X (280 characters) if (hasText && data.text!.length > 280) { errors.push("X post text cannot exceed 280 characters"); } // Validate hashtags if (data.hashtags) { for (const hashtag of data.hashtags) { if (hashtag.includes(' ')) { errors.push(`Hashtag "${hashtag}" cannot contain spaces`); } if (hashtag.length > 50) { errors.push(`Hashtag "${hashtag}" is too long (max 50 characters)`); } } } return { isValid: errors.length === 0, errors, missingRequired }; } private async handleXPost(data: PostData): Promise<typeof CallToolResultSchema._type> { const validation = this.validatePostData(data); if (!validation.isValid) { return { content: [ { type: "text", text: `Validation failed:\n${validation.errors.join('\n')}\n\nMissing required fields: ${validation.missingRequired.join(', ')}` } ], isError: false }; } // Build the shareable URL for X with all content const params = new URLSearchParams(); let fullText = ''; if (data.text) { fullText += data.text; } if (data.hashtags && data.hashtags.length > 0) { const hashtagText = data.hashtags.map(tag => `#${tag}`).join(' '); fullText += ` ${hashtagText}`; } // Add text parameter if (fullText.trim()) { params.append('text', fullText.trim()); } // Add URL parameter if media is provided if (data.image) { params.append('url', data.image); } else if (data.video) { params.append('url', data.video); } const shareableUrl = `https://x.com/intent/tweet?${params.toString()}`; return { content: [ { type: "text", text: `āœ… X Post created successfully!\n\nšŸ“ Content: ${data.text || 'No text'}\nšŸ–¼ļø Image: ${data.image ? 'Yes' : 'No'}\nšŸŽ„ Video: ${data.video ? 'Yes' : 'No'}\nšŸ·ļø Hashtags: ${data.hashtags?.map(tag => `#${tag}`).join(' ') || 'None'}\n\nšŸ”— Shareable Link: ${shareableUrl}\n\nClick the link above to open X with your post pre-filled!` } ], isError: false }; } private async handleLinkedInPost(data: PostData): Promise<typeof CallToolResultSchema._type> { const validation = this.validatePostData(data); if (!validation.isValid) { return { content: [ { type: "text", text: `Validation failed:\n${validation.errors.join('\n')}\n\nMissing required fields: ${validation.missingRequired.join(', ')}` } ], isError: false }; } // For LinkedIn, we create a shareable link with proper parameters const params = new URLSearchParams(); let postText = ''; if (data.text) { postText += data.text; } if (data.hashtags && data.hashtags.length > 0) { const hashtagText = data.hashtags.map(tag => `#${tag}`).join(' '); postText += `\n\n${hashtagText}`; } // Add URL parameter if media is provided if (data.image) { params.append('url', data.image); } else if (data.video) { params.append('url', data.video); } // Add title parameter if (postText.trim()) { params.append('title', postText.trim()); } // Add summary parameter (can include additional text) if (data.text && data.text.length > 100) { params.append('summary', data.text.substring(0, 200) + '...'); } const linkedinUrl = `https://www.linkedin.com/shareArticle/?${params.toString()}`; let response = `āœ… LinkedIn Post created successfully!\n\nšŸ“ Post Text (copy this):\n\n"${postText}"\n\n`; if (data.image) { response += `šŸ–¼ļø Image URL: ${data.image}\n`; } if (data.video) { response += `šŸŽ„ Video URL: ${data.video}\n`; } response += `\nšŸ”— LinkedIn Share Link: ${linkedinUrl}\n\nšŸ“‹ Instructions:\n1. Click the LinkedIn link above to open with pre-filled content\n2. If the media doesn't preview automatically, add it manually\n3. Review and publish your post!\n\nNote: Public URLs (like YouTube, public images) will preview automatically. Private URLs (like Google Drive) may need manual addition.`; return { content: [ { type: "text", text: response } ], isError: false }; } async run(): Promise<void> { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error(`šŸš€ ${this.serverName} MCP Server iniciado`); } } // Executar o servidor apenas se for chamado diretamente if (import.meta.url === `file://${process.argv[1]}`) { const server = new PostMakerMCPServer(); server.run().catch(console.error); }