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:
@@ -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
136
src/clients/twitter.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
50
src/index.ts
50
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<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)) {
|
||||
|
||||
113
src/manifest.ts
113
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: '🐦',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
88
src/tools.ts
88
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<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');
|
||||
|
||||
Reference in New Issue
Block a user