Skip to main content
Glama

X MCP Server

by DataWhisker
index.ts14 kB
#!/usr/bin/env node import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, Tool, TextContent } from '@modelcontextprotocol/sdk/types.js'; import { TwitterApi } from 'twitter-api-v2'; import { readFile, stat } from 'fs/promises'; import { resolve, basename } from 'path'; // Helper function to upload media with validation async function uploadImage(client: TwitterApi, imagePath: string): Promise<string> { // Sanitize path to prevent directory traversal const sanitizedPath = resolve(imagePath); // Validate file exists and get stats let fileStats; try { fileStats = await stat(sanitizedPath); } catch (error) { throw new Error(`File not found: ${basename(sanitizedPath)}`); } // Check if it's a file (not a directory) if (!fileStats.isFile()) { throw new Error(`Path is not a file: ${basename(sanitizedPath)}`); } // Twitter image size limit is 5MB const MAX_SIZE = 5 * 1024 * 1024; // 5MB in bytes if (fileStats.size > MAX_SIZE) { throw new Error(`File size exceeds 5MB limit (${(fileStats.size / 1024 / 1024).toFixed(2)}MB)`); } // Detect mime type from file extension const ext = sanitizedPath.toLowerCase().split('.').pop(); const mimeTypes: { [key: string]: string } = { 'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'gif': 'image/gif', 'webp': 'image/webp' }; // Validate supported format if (!ext || !mimeTypes[ext]) { const supported = Object.keys(mimeTypes).join(', '); throw new Error(`Unsupported file format. Supported formats: ${supported}`); } const mimeType = mimeTypes[ext]; // Read and upload file const imageBuffer = await readFile(sanitizedPath); return await client.v1.uploadMedia(imageBuffer, { mimeType }); } // Helper function to upload video with chunked upload async function uploadVideo(client: TwitterApi, videoPath: string): Promise<string> { // Sanitize path to prevent directory traversal const sanitizedPath = resolve(videoPath); // Validate file exists and get stats let fileStats; try { fileStats = await stat(sanitizedPath); } catch (error) { throw new Error(`File not found: ${basename(sanitizedPath)}`); } // Check if it's a file (not a directory) if (!fileStats.isFile()) { throw new Error(`Path is not a file: ${basename(sanitizedPath)}`); } // Twitter video size limit is 512MB const MAX_SIZE = 512 * 1024 * 1024; // 512MB in bytes if (fileStats.size > MAX_SIZE) { throw new Error(`Video size exceeds 512MB limit (${(fileStats.size / 1024 / 1024).toFixed(2)}MB)`); } // Detect mime type from file extension const ext = sanitizedPath.toLowerCase().split('.').pop(); const mimeTypes: { [key: string]: string } = { 'mp4': 'video/mp4', 'mov': 'video/quicktime', 'avi': 'video/x-msvideo', 'webm': 'video/webm', 'm4v': 'video/x-m4v' }; // Validate supported format if (!ext || !mimeTypes[ext]) { const supported = Object.keys(mimeTypes).join(', '); throw new Error(`Unsupported video format. Supported formats: ${supported}`); } const mimeType = mimeTypes[ext]; // Read video file const videoBuffer = await readFile(sanitizedPath); // Upload video using chunked upload return await client.v1.uploadMedia(videoBuffer, { mimeType, target: 'tweet', additionalOwners: undefined, longVideo: fileStats.size > 15 * 1024 * 1024 // Use long video for files > 15MB }); } // Track rate limit reset times const rateLimitResets: { [key: string]: number } = { 'home': 0, 'tweet': 0, 'reply': 0, 'delete': 0 }; // Helper function for rate limit handling async function withRateLimit<T>(endpoint: 'home' | 'tweet' | 'reply' | 'delete', fn: () => Promise<T>): Promise<T> { const now = Date.now(); const resetTime = rateLimitResets[endpoint]; if (now < resetTime) { const waitTime = resetTime - now + 1000; // Add 1 second buffer await new Promise(resolve => setTimeout(resolve, waitTime)); } try { const result = await fn(); // Set next reset time to 15 minutes from now for free tier rateLimitResets[endpoint] = now + (15 * 60 * 1000); return result; } catch (error: any) { if (error?.code === 429) { // If we get a rate limit error, wait 15 minutes before next attempt rateLimitResets[endpoint] = now + (15 * 60 * 1000); throw new McpError( ErrorCode.InvalidRequest, `Rate limit exceeded for ${endpoint}. Please try again in 15 minutes.` ); } throw error; } } // Twitter API client setup const client = new TwitterApi({ appKey: process.env.TWITTER_API_KEY ?? '', appSecret: process.env.TWITTER_API_SECRET ?? '', accessToken: process.env.TWITTER_ACCESS_TOKEN ?? '', accessSecret: process.env.TWITTER_ACCESS_SECRET ?? '', }); class XMcpServer { private server: Server; constructor() { this.server = new Server( { name: 'x-mcp-server', version: '1.0.0', }, { capabilities: { tools: {}, }, } ); this.setupToolHandlers(); // Error handling this.server.onerror = (error) => console.error('[MCP Error]', error); process.on('SIGINT', async () => { await this.server.close(); process.exit(0); }); } private setupToolHandlers() { this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: 'get_home_timeline', description: 'Get the most recent tweets from your home timeline', inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'Number of tweets to retrieve (max 100)', minimum: 1, maximum: 100, default: 20, }, }, }, }, { name: 'create_tweet', description: 'Create a new tweet with optional image or video attachment', inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'The text content of the tweet', maxLength: 280, }, image_path: { type: 'string', description: 'Optional absolute path to an image file to attach (PNG, JPEG, GIF, WEBP)', }, video_path: { type: 'string', description: 'Optional absolute path to a video file to attach (MP4, MOV, AVI, WEBM, M4V). Max 512MB. Cannot be used with image_path.', }, }, required: ['text'], }, }, { name: 'reply_to_tweet', description: 'Reply to a tweet with optional image or video attachment', inputSchema: { type: 'object', properties: { tweet_id: { type: 'string', description: 'The ID of the tweet to reply to', }, text: { type: 'string', description: 'The text content of the reply', maxLength: 280, }, image_path: { type: 'string', description: 'Optional absolute path to an image file to attach (PNG, JPEG, GIF, WEBP)', }, video_path: { type: 'string', description: 'Optional absolute path to a video file to attach (MP4, MOV, AVI, WEBM, M4V). Max 512MB. Cannot be used with image_path.', }, }, required: ['tweet_id', 'text'], }, }, { name: 'delete_tweet', description: 'Delete one of your tweets', inputSchema: { type: 'object', properties: { tweet_id: { type: 'string', description: 'The ID of the tweet to delete', }, }, required: ['tweet_id'], }, }, ], })); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { try { switch (request.params.name) { case 'get_home_timeline': { const { limit = 20 } = request.params.arguments as { limit?: number }; const timeline = await withRateLimit('home', () => client.v2.homeTimeline({ max_results: Math.min(limit, 5), // Limit to max 5 tweets for free tier 'tweet.fields': ['author_id', 'created_at', 'referenced_tweets'], expansions: ['author_id', 'referenced_tweets.id'], })); return { content: [ { type: 'text', text: JSON.stringify(timeline.data, null, 2), }, ], }; } case 'create_tweet': { const { text, image_path, video_path } = request.params.arguments as { text: string; image_path?: string; video_path?: string; }; // Validate that both image and video aren't provided if (image_path && video_path) { throw new McpError( ErrorCode.InvalidRequest, 'Cannot attach both image and video to the same tweet. Please provide only one.' ); } let mediaId: string | undefined; // Upload media if image_path is provided if (image_path) { try { mediaId = await uploadImage(client, image_path); } catch (error) { throw new McpError( ErrorCode.InvalidRequest, `Failed to upload image: ${(error as Error).message}` ); } } // Upload video if video_path is provided if (video_path) { try { mediaId = await uploadVideo(client, video_path); } catch (error) { throw new McpError( ErrorCode.InvalidRequest, `Failed to upload video: ${(error as Error).message}` ); } } const tweet = await withRateLimit('tweet', () => mediaId ? client.v2.tweet({ text, media: { media_ids: [mediaId] } }) : client.v2.tweet(text) ); return { content: [ { type: 'text', text: JSON.stringify(tweet.data, null, 2), }, ], }; } case 'reply_to_tweet': { const { tweet_id, text, image_path, video_path } = request.params.arguments as { tweet_id: string; text: string; image_path?: string; video_path?: string; }; // Validate that both image and video aren't provided if (image_path && video_path) { throw new McpError( ErrorCode.InvalidRequest, 'Cannot attach both image and video to the same reply. Please provide only one.' ); } let mediaId: string | undefined; // Upload media if image_path is provided if (image_path) { try { mediaId = await uploadImage(client, image_path); } catch (error) { throw new McpError( ErrorCode.InvalidRequest, `Failed to upload image: ${(error as Error).message}` ); } } // Upload video if video_path is provided if (video_path) { try { mediaId = await uploadVideo(client, video_path); } catch (error) { throw new McpError( ErrorCode.InvalidRequest, `Failed to upload video: ${(error as Error).message}` ); } } const reply = await withRateLimit('reply', () => mediaId ? client.v2.tweet({ text, reply: { in_reply_to_tweet_id: tweet_id }, media: { media_ids: [mediaId] } }) : client.v2.reply(text, tweet_id) ); return { content: [ { type: 'text', text: JSON.stringify(reply.data, null, 2), }, ], }; } case 'delete_tweet': { const { tweet_id } = request.params.arguments as { tweet_id: string }; const deleted = await withRateLimit('delete', () => client.v2.deleteTweet(tweet_id)); return { content: [ { type: 'text', text: JSON.stringify(deleted.data, null, 2), }, ], }; } default: throw new McpError( ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}` ); } } catch (error) { if (error instanceof McpError) { throw error; } throw new McpError( ErrorCode.InternalError, `Twitter API error: ${(error as Error).message}` ); } }); } async run() { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error('X MCP server running on stdio'); } } const server = new XMcpServer(); server.run().catch(console.error);

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/DataWhisker/x-mcp-server'

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