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
This commit is contained in:
Garfield
2026-05-05 22:11:19 -04:00
parent 136bc257d1
commit 59501f11f1
5 changed files with 394 additions and 0 deletions

View File

@@ -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}_*

136
src/clients/twitter.ts Normal file
View File

@@ -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<string, string>
) {
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<Array<{
id: string;
text: string;
author_id?: string;
created_at?: string;
}>> {
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<Array<{
id: string;
text: string;
created_at?: string;
}>> {
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'
);
}

View File

@@ -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<string, unknown>;
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)) {

View File

@@ -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: '🐦',
},
},
};
}

View File

@@ -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<string, unknown>): 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');