post-maker-mcp
Version:
MCP server for creating posts on X and LinkedIn
289 lines (247 loc) ⢠9.35 kB
text/typescript
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);
}