From 59501f11f1ca9f859c67ca69b96ad6b657dbb55d Mon Sep 17 00:00:00 2001 From: Garfield Date: Tue, 5 May 2026 22:11:19 -0400 Subject: [PATCH] feat: Twitter/X integration (read-only free tier) - New client: src/clients/twitter.ts - Tools: twitter_search_tweets, twitter_get_user_profile, twitter_get_user_tweets, twitter_create_tweet - REST endpoints: GET /api/twitter/search, /api/twitter/user, /api/twitter/tweets, POST /api/twitter/tweet - Multi-account env var: TWITTER_{ACCOUNT}_BEARER_TOKEN - twitter_create_tweet returns clear error about paid tier requirement Total tools: 36 --- .env.example | 7 +++ src/clients/twitter.ts | 136 +++++++++++++++++++++++++++++++++++++++++ src/index.ts | 50 +++++++++++++++ src/manifest.ts | 113 ++++++++++++++++++++++++++++++++++ src/tools.ts | 88 ++++++++++++++++++++++++++ 5 files changed, 394 insertions(+) create mode 100644 src/clients/twitter.ts diff --git a/.env.example b/.env.example index b3c4891..c0a6ca8 100644 --- a/.env.example +++ b/.env.example @@ -74,3 +74,10 @@ DISCORD_DEFAULT_BOT_TOKEN=your-discord-bot-token INSTAGRAM_DEFAULT_ACCESS_TOKEN=your-instagram-access-token INSTAGRAM_DEFAULT_BUSINESS_ACCOUNT_ID=your-instagram-business-account-id # For additional accounts, duplicate with INSTAGRAM_{ACCOUNT}_* + +# ── Twitter/X API ──────────────────────────────────────────────────────────── +# Get a Bearer Token from https://developer.twitter.com/en/portal/dashboard +# Free tier: 500 tweets/month read-only. Paid tiers required for posting. +# For default account: +TWITTER_DEFAULT_BEARER_TOKEN=your-twitter-bearer-token +# For additional accounts, duplicate with TWITTER_{ACCOUNT}_* diff --git a/src/clients/twitter.ts b/src/clients/twitter.ts new file mode 100644 index 0000000..b9be3b0 --- /dev/null +++ b/src/clients/twitter.ts @@ -0,0 +1,136 @@ +const TWITTER_API_BASE = 'https://api.twitter.com/2'; + +function getBearerToken(account: string): string { + const envKey = `TWITTER_${account.toUpperCase()}_BEARER_TOKEN`; + return process.env[envKey] ?? ''; +} + +async function twitterRequest( + endpoint: string, + bearerToken: string, + params?: Record +) { + const url = new URL(`${TWITTER_API_BASE}${endpoint}`); + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value) url.searchParams.append(key, value); + }); + } + + const res = await fetch(url.toString(), { + headers: { + 'Authorization': `Bearer ${bearerToken}`, + }, + signal: AbortSignal.timeout(15000), + }); + + if (!res.ok) { + const error = await res.text(); + throw new Error(`Twitter API error (${res.status}): ${error}`); + } + + return res.json(); +} + +export async function searchTweets(args: { + query: string; + max_results?: number; + account?: string; +}): Promise> { + const bearerToken = getBearerToken(args.account ?? 'default'); + if (!bearerToken) { + throw new Error('Missing Twitter credentials. Set TWITTER_{ACCOUNT}_BEARER_TOKEN'); + } + + const data = await twitterRequest('/tweets/search/recent', bearerToken, { + query: args.query, + max_results: String(Math.min(args.max_results ?? 10, 100)), + 'tweet.fields': 'created_at,author_id', + }); + + return (data.data ?? []) as Array<{ id: string; text: string; author_id?: string; created_at?: string }>; +} + +export async function getUserProfile(args: { + username: string; + account?: string; +}): Promise<{ + id: string; + name: string; + username: string; + description?: string; + followers_count?: number; + following_count?: number; + tweet_count?: number; +}> { + const bearerToken = getBearerToken(args.account ?? 'default'); + if (!bearerToken) { + throw new Error('Missing Twitter credentials. Set TWITTER_{ACCOUNT}_BEARER_TOKEN'); + } + + const data = await twitterRequest(`/users/by/username/${args.username}`, bearerToken, { + 'user.fields': 'description,public_metrics', + }); + + const user = data.data as { id: string; name: string; username: string; description?: string; public_metrics?: { followers_count?: number; following_count?: number; tweet_count?: number } }; + + return { + id: user.id, + name: user.name, + username: user.username, + description: user.description, + followers_count: user.public_metrics?.followers_count, + following_count: user.public_metrics?.following_count, + tweet_count: user.public_metrics?.tweet_count, + }; +} + +export async function getUserTweets(args: { + username: string; + max_results?: number; + account?: string; +}): Promise> { + const bearerToken = getBearerToken(args.account ?? 'default'); + if (!bearerToken) { + throw new Error('Missing Twitter credentials. Set TWITTER_{ACCOUNT}_BEARER_TOKEN'); + } + + // First get user ID + const userData = await twitterRequest(`/users/by/username/${args.username}`, bearerToken); + const userId = userData.data?.id; + if (!userId) { + throw new Error(`User @${args.username} not found`); + } + + const data = await twitterRequest(`/users/${userId}/tweets`, bearerToken, { + max_results: String(Math.min(args.max_results ?? 10, 100)), + 'tweet.fields': 'created_at', + }); + + return (data.data ?? []) as Array<{ id: string; text: string; created_at?: string }>; +} + +export async function createTweet(args: { + text: string; + account?: string; +}): Promise<{ message: string }> { + const bearerToken = getBearerToken(args.account ?? 'default'); + if (!bearerToken) { + throw new Error('Missing Twitter credentials. Set TWITTER_{ACCOUNT}_BEARER_TOKEN'); + } + + throw new Error( + 'Twitter/X posting requires a paid API tier. ' + + 'The free tier is read-only (500 tweets/month). ' + + 'Upgrade to Basic ($100/month) or Pro ($5,000/month) at https://developer.twitter.com/en/portal/products' + ); +} diff --git a/src/index.ts b/src/index.ts index 499b221..6b21d42 100644 --- a/src/index.ts +++ b/src/index.ts @@ -769,6 +769,56 @@ app.post('/api/instagram/post', requireAuth, async (req, res) => { } }); +// ── Twitter/X REST endpoints ──────────────────────────────────── +app.get('/api/twitter/search', requireAuth, async (req, res) => { + const query = req.query.query as string | undefined; + const max_results = req.query.max_results ? parseInt(req.query.max_results as string, 10) : undefined; + const account = req.query.account as string | undefined; + if (!query) { res.status(400).json({ error: 'query is required' }); return; } + try { + const result = await handleToolCall('twitter_search_tweets', { query, max_results, account }); + res.json(parseToolResult(result)); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +app.get('/api/twitter/user', requireAuth, async (req, res) => { + const username = req.query.username as string | undefined; + const account = req.query.account as string | undefined; + if (!username) { res.status(400).json({ error: 'username is required' }); return; } + try { + const result = await handleToolCall('twitter_get_user_profile', { username, account }); + res.json(parseToolResult(result)); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +app.get('/api/twitter/tweets', requireAuth, async (req, res) => { + const username = req.query.username as string | undefined; + const max_results = req.query.max_results ? parseInt(req.query.max_results as string, 10) : undefined; + const account = req.query.account as string | undefined; + if (!username) { res.status(400).json({ error: 'username is required' }); return; } + try { + const result = await handleToolCall('twitter_get_user_tweets', { username, max_results, account }); + res.json(parseToolResult(result)); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +app.post('/api/twitter/tweet', requireAuth, async (req, res) => { + const { text, account } = req.body as Record; + if (!text) { res.status(400).json({ error: 'text is required' }); return; } + try { + const result = await handleToolCall('twitter_create_tweet', { text, account }); + res.json(parseToolResult(result)); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + app.post('/api/pilot-request', async (req, res) => { const origin = req.get('origin'); if (origin && !SQUAREMCP_ALLOWED_ORIGINS.has(origin)) { diff --git a/src/manifest.ts b/src/manifest.ts index c53e8d6..374d30e 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -911,6 +911,115 @@ export function getManifest(serverUrl: string, authEnabled: boolean) { examples: [{ image_url: 'https://example.com/photo.jpg', caption: 'New post!', account: 'default' }], }, + // ── Twitter/X tools ──────────────────────────────────────────────────── + { + name: 'twitter_search_tweets', + category: 'twitter', + description: 'Search recent tweets on Twitter/X', + when_to_use: + 'User wants to find tweets about a topic, hashtag, keyword, or trend.', + input_schema: { + type: 'object', + required: ['query'], + properties: { + query: { type: 'string', description: 'Search query (keyword, hashtag #xxx, from:username)' }, + max_results: { type: 'number', description: 'Max tweets (default: 10, max: 100)' }, + account: { type: 'string', description: 'Which Twitter account to use (default: "default")' }, + }, + }, + returns: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Tweet ID' }, + text: { type: 'string' }, + author_id: { type: 'string' }, + created_at: { type: 'string', format: 'date-time' }, + }, + }, + }, + examples: [{ query: '#AI', max_results: 10, account: 'default' }], + }, + { + name: 'twitter_get_user_profile', + category: 'twitter', + description: 'Get a Twitter/X user profile and stats', + when_to_use: + 'User wants follower count, bio, or profile info for a specific Twitter account.', + input_schema: { + type: 'object', + required: ['username'], + properties: { + username: { type: 'string', description: 'Twitter username without @' }, + account: { type: 'string', description: 'Which Twitter account to use (default: "default")' }, + }, + }, + returns: { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + username: { type: 'string' }, + description: { type: 'string' }, + followers_count: { type: 'number' }, + following_count: { type: 'number' }, + tweet_count: { type: 'number' }, + }, + }, + examples: [{ username: 'elonmusk', account: 'default' }], + }, + { + name: 'twitter_get_user_tweets', + category: 'twitter', + description: 'Get recent tweets from a specific user', + when_to_use: + 'User wants to read someones recent tweets or timeline.', + input_schema: { + type: 'object', + required: ['username'], + properties: { + username: { type: 'string', description: 'Twitter username without @' }, + max_results: { type: 'number', description: 'Max tweets (default: 10, max: 100)' }, + account: { type: 'string', description: 'Which Twitter account to use (default: "default")' }, + }, + }, + returns: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + text: { type: 'string' }, + created_at: { type: 'string', format: 'date-time' }, + }, + }, + }, + examples: [{ username: 'elonmusk', max_results: 5, account: 'default' }], + }, + { + name: 'twitter_create_tweet', + category: 'twitter', + description: 'Post a tweet [REQUIRES PAID TIER]', + when_to_use: + 'User wants to post a tweet. NOTE: Free tier is read-only. Paid upgrade required.', + input_schema: { + type: 'object', + required: ['text'], + properties: { + text: { type: 'string', description: 'Tweet text (max 280 chars)' }, + account: { type: 'string', description: 'Which Twitter account to use (default: "default")' }, + }, + }, + returns: { + type: 'object', + properties: { + message: { type: 'string', description: 'Error or confirmation' }, + }, + }, + examples: [{ text: 'Hello from Hermes MCP!', account: 'default' }], + }, + // ── Obsidian tools ────────────────────────────────────────────────────── { name: 'obsidian_search_notes', @@ -1090,6 +1199,10 @@ export function getManifest(serverUrl: string, authEnabled: boolean) { description: 'Instagram Business/Creator account via Graph API', icon: '📸', }, + twitter: { + description: 'Twitter/X search and profile lookup (read-only on free tier)', + icon: '🐦', + }, }, }; } diff --git a/src/tools.ts b/src/tools.ts index ca5ec5c..9a30505 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -7,6 +7,7 @@ import { getProfile as getLinkedInProfile, createPost as createLinkedInPost, sea import { getMe as getTelegramMe, sendMessage as sendTelegramMessage, sendPhoto as sendTelegramPhoto, getUpdates as getTelegramUpdates, getChat as getTelegramChat } from './clients/telegram.js'; import { getMe as getDiscordMe, getGuilds, getChannels, sendMessage as sendDiscordMessage, getMessages as getDiscordMessages } from './clients/discord.js'; import { getProfile as getInstagramProfile, getMedia as getInstagramMedia, createPost as createInstagramPost } from './clients/instagram.js'; +import { searchTweets, getUserProfile, getUserTweets, createTweet } from './clients/twitter.js'; const ACCOUNT_PARAM = { account: { @@ -459,6 +460,62 @@ export const tools: Tool[] = [ }, }, }, + + // ── Twitter/X tools ────────────────────────────────────────── + { + name: 'twitter_search_tweets', + description: + 'Search recent tweets on Twitter/X. Use when the user wants to find tweets about a topic, hashtag, or keyword.', + inputSchema: { + type: 'object', + required: ['query'], + properties: { + query: { type: 'string', description: 'Search query (keyword, hashtag, or phrase)' }, + max_results: { type: 'number', description: 'Max tweets to return (default: 10, max: 100)' }, + account: { type: 'string', description: 'Which Twitter account to use (default: "default")' }, + }, + }, + }, + { + name: 'twitter_get_user_profile', + description: + 'Get a Twitter/X user profile. Use when the user wants stats, bio, or follower count for a specific account.', + inputSchema: { + type: 'object', + required: ['username'], + properties: { + username: { type: 'string', description: 'Twitter username (without @)' }, + account: { type: 'string', description: 'Which Twitter account to use (default: "default")' }, + }, + }, + }, + { + name: 'twitter_get_user_tweets', + description: + 'Get recent tweets from a specific Twitter/X user. Use to read someones timeline.', + inputSchema: { + type: 'object', + required: ['username'], + properties: { + username: { type: 'string', description: 'Twitter username (without @)' }, + max_results: { type: 'number', description: 'Max tweets (default: 10, max: 100)' }, + account: { type: 'string', description: 'Which Twitter account to use (default: "default")' }, + }, + }, + }, + { + name: 'twitter_create_tweet', + description: + 'Post a tweet on Twitter/X. [REQUIRES PAID TIER] The free API tier is read-only. Upgrade required to post.', + inputSchema: { + type: 'object', + required: ['text'], + properties: { + text: { type: 'string', description: 'Tweet text (max 280 chars)' }, + account: { type: 'string', description: 'Which Twitter account to use (default: "default")' }, + }, + }, + }, ]; function acct(args: Record): Account { @@ -689,6 +746,37 @@ export async function handleToolCall( }); break; + // ── Twitter/X ─────────────────────────────────────────────── + case 'twitter_search_tweets': + result = await searchTweets({ + query: args.query as string, + max_results: (args.max_results as number) ?? 10, + account: args.account as string | undefined, + }); + break; + + case 'twitter_get_user_profile': + result = await getUserProfile({ + username: args.username as string, + account: args.account as string | undefined, + }); + break; + + case 'twitter_get_user_tweets': + result = await getUserTweets({ + username: args.username as string, + max_results: (args.max_results as number) ?? 10, + account: args.account as string | undefined, + }); + break; + + case 'twitter_create_tweet': + result = await createTweet({ + text: args.text as string, + account: args.account as string | undefined, + }); + break; + // Legacy Yahoo-prefixed names — keep working for any cached Claude sessions case 'yahoo_get_profile': result = await getProfile('yahoo');