# Directory Structure
```
├── .github
│ ├── FUNDING.yml
│ └── workflows
│ ├── publish.yml
│ └── release.yml
├── .gitignore
├── Dockerfile
├── LICENSE
├── package-lock.json
├── package.json
├── README.md
├── smithery.yaml
├── src
│ ├── evals
│ │ └── evals.ts
│ ├── formatter.ts
│ ├── index.ts
│ ├── twitter-api.ts
│ └── types.ts
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
node_modules/
build/
*.log
.env*
.vscode/
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
[](https://mseep.ai/app/enescinr-twitter-mcp)
# Twitter MCP Server
[](https://smithery.ai/server/@enescinar/twitter-mcp)
This MCP server allows Clients to interact with Twitter, enabling posting tweets and searching Twitter.
<a href="https://glama.ai/mcp/servers/dhsudtc7cd">
<img width="380" height="200" src="https://glama.ai/mcp/servers/dhsudtc7cd/badge" alt="Twitter Server MCP server" />
</a>
## Quick Start
1. Create a Twitter Developer account and get your API keys from [Twitter Developer Portal](https://developer.twitter.com/en/portal/dashboard)
2. Add this configuration to your Claude Desktop config file:
**Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
**macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
```json
{
"mcpServers": {
"twitter-mcp": {
"command": "npx",
"args": ["-y", "@enescinar/twitter-mcp"],
"env": {
"API_KEY": "your_api_key_here",
"API_SECRET_KEY": "your_api_secret_key_here",
"ACCESS_TOKEN": "your_access_token_here",
"ACCESS_TOKEN_SECRET": "your_access_token_secret_here"
}
}
}
}
```
3. Restart Claude Desktop
That's it! Claude can now interact with Twitter through two tools:
- `post_tweet`: Post a new tweet
- `search_tweets`: Search for tweets
## Example Usage
Try asking Claude:
- "Can you post a tweet saying 'Hello from Claude!'"
- "Can you search for tweets about Claude AI?"
## Troubleshooting
Logs can be found at:
- **Windows**: `%APPDATA%\Claude\logs\mcp-server-twitter.log`
- **macOS**: `~/Library/Logs/Claude/mcp-server-twitter.log`
## Development
If you want to contribute or run from source:
1. Clone the repository:
```bash
git clone https://github.com/EnesCinr/twitter-mcp.git
cd twitter-mcp
```
2. Install dependencies:
```bash
npm install
```
3. Build:
```bash
npm run build
```
4. Run:
```bash
npm start
```
## Running evals
The evals package loads an mcp client that then runs the index.ts file, so there is no need to rebuild between tests. You can load environment variables by prefixing the npx command. Full documentation can be found [here](https://www.mcpevals.io/docs).
```bash
OPENAI_API_KEY=your-key npx mcp-eval src/evals/evals.ts src/index.ts
```
## License
MIT
```
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
```
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
```yaml
name: Publish to NPM
on:
release:
types: [created]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v2
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies and build 🔧
run: npm ci
- name: Publish package on NPM 📦
run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
```
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
```yaml
name: Releases
on:
push:
branches:
- main
jobs:
changelog:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Conventional Changelog Action
id: changelog
uses: TriPSs/[email protected]
with:
github-token: ${{ secrets.PA_TOKEN }}
version-file: './package.json,./package-lock.json'
- name: create release
uses: actions/create-release@v1
if: ${{ steps.changelog.outputs.skipped == 'false' }}
env:
GITHUB_TOKEN: ${{ secrets.PA_TOKEN }}
with:
tag_name: ${{ steps.changelog.outputs.tag }}
release_name: ${{ steps.changelog.outputs.tag }}
body: ${{ steps.changelog.outputs.clean_changelog }}
```
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
```yaml
# These are supported funding model platforms
github: [EnesCinr]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
```
--------------------------------------------------------------------------------
/smithery.yaml:
--------------------------------------------------------------------------------
```yaml
# Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml
startCommand:
type: stdio
configSchema:
# JSON Schema defining the configuration options for the MCP.
type: object
required:
- apiKey
- apiSecretKey
- accessToken
- accessTokenSecret
properties:
apiKey:
type: string
description: Twitter API key.
apiSecretKey:
type: string
description: Twitter API secret key.
accessToken:
type: string
description: Twitter access token.
accessTokenSecret:
type: string
description: Twitter access token secret.
commandFunction:
# A function that produces the CLI command to start the MCP on stdio.
|-
config => ({command: 'node', args: ['build/index.js'], env: {API_KEY: config.apiKey, API_SECRET_KEY: config.apiSecretKey, ACCESS_TOKEN: config.accessToken, ACCESS_TOKEN_SECRET: config.accessTokenSecret}})
```
--------------------------------------------------------------------------------
/src/evals/evals.ts:
--------------------------------------------------------------------------------
```typescript
//evals.ts
import { EvalConfig } from 'mcp-evals';
import { openai } from "@ai-sdk/openai";
import { grade, EvalFunction } from "mcp-evals";
const post_tweetEval: EvalFunction = {
name: "post_tweet Evaluation",
description: "Evaluates the functionality of posting a new tweet to Twitter",
run: async () => {
const result = await grade(openai("gpt-4"), "Please post a tweet saying: 'Excited to announce our new feature launch! #NewFeature'");
return JSON.parse(result);
}
};
const search_tweetsEval: EvalFunction = {
name: 'search_tweets Tool Evaluation',
description: 'Evaluates the search_tweets tool functionality',
run: async () => {
const result = await grade(openai("gpt-4"), "Please search for tweets about '#AI' with 15 results.");
return JSON.parse(result);
}
};
const config: EvalConfig = {
model: openai("gpt-4"),
evals: [post_tweetEval, search_tweetsEval]
};
export default config;
export const evals = [post_tweetEval, search_tweetsEval];
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
{
"name": "@enescinar/twitter-mcp",
"version": "0.2.0",
"description": "A Model Context Protocol server allows to interact with Twitter, enabling posting tweets and searching Twitter.",
"type": "module",
"main": "build/index.js",
"bin": {
"twitter-server": "./build/index.js"
},
"files": [
"build",
"README.md"
],
"scripts": {
"build": "tsc",
"start": "node build/index.js",
"prepublishOnly": "npm run build"
},
"keywords": [
"mcp",
"modelcontextprotocol",
"server",
"twitter",
"claude"
],
"author": "Enes Cinar",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "0.6.0",
"dotenv": "^16.4.7",
"mcp-evals": "^1.0.18",
"twitter-api-v2": "^1.18.2",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/node": "^20.11.24",
"typescript": "^5.3.3"
},
"repository": {
"type": "git",
"url": "git+https://github.com/EnesCinr/twitter-mcp.git"
},
"bugs": {
"url": "https://github.com/EnesCinr/twitter-mcp/issues"
},
"homepage": "https://github.com/EnesCinr/twitter-mcp#readme"
}
```
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
```dockerfile
# Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile
# Use a Node.js image for building the server
FROM node:18-alpine AS builder
# Set the working directory in the container
WORKDIR /app
# Copy package.json and package-lock.json to the working directory
COPY package.json package-lock.json ./
# Install dependencies
RUN npm install
# Copy the entire source code into the working directory
COPY . .
# Build the TypeScript files
RUN npm run build
# Use a smaller Node.js image for the runtime
FROM node:18-slim
# Set the working directory in the runtime image
WORKDIR /app
# Copy the build files from the builder image
COPY --from=builder /app/build ./build
# Copy package.json and package-lock.json for production install
COPY package.json package-lock.json ./
# Install only production dependencies
RUN npm install --omit=dev
# Set environment variables for Twitter API
ENV API_KEY=your_api_key_here
ENV API_SECRET_KEY=your_api_secret_key_here
ENV ACCESS_TOKEN=your_access_token_here
ENV ACCESS_TOKEN_SECRET=your_access_token_secret_here
# Start the server
CMD ["node", "build/index.js"]
```
--------------------------------------------------------------------------------
/src/formatter.ts:
--------------------------------------------------------------------------------
```typescript
import { FormattedTweet, Tweet, TwitterUser, SearchResponse } from './types.js';
export class ResponseFormatter {
static formatTweet(tweet: Tweet, user: TwitterUser, position: number): FormattedTweet {
return {
position,
author: {
username: user.username
},
content: tweet.text,
metrics: tweet.metrics,
url: `https://twitter.com/${user.username}/status/${tweet.id}`
};
}
static formatSearchResponse(
query: string,
tweets: Tweet[],
users: TwitterUser[]
): SearchResponse {
const userMap = new Map(users.map(user => [user.id, user]));
const formattedTweets = tweets
.map((tweet, index) => {
const user = userMap.get(tweet.authorId);
if (!user) return null;
return this.formatTweet(tweet, user, index + 1);
})
.filter((tweet): tweet is FormattedTweet => tweet !== null);
return {
query,
count: formattedTweets.length,
tweets: formattedTweets
};
}
static toMcpResponse(response: SearchResponse): string {
const header = [
'TWITTER SEARCH RESULTS',
`Query: "${response.query}"`,
`Found ${response.count} tweets`,
'='
].join('\n');
if (response.count === 0) {
return header + '\nNo tweets found matching your query.';
}
const tweetBlocks = response.tweets.map(tweet => [
`Tweet #${tweet.position}`,
`From: @${tweet.author.username}`,
`Content: ${tweet.content}`,
`Metrics: ${tweet.metrics.likes} likes, ${tweet.metrics.retweets} retweets`,
`URL: ${tweet.url}`,
'='
].join('\n'));
return [header, ...tweetBlocks].join('\n\n');
}
}
```
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
```typescript
import { z } from 'zod';
// Configuration schema with validation
export const ConfigSchema = z.object({
apiKey: z.string().min(1, 'API Key is required'),
apiSecretKey: z.string().min(1, 'API Secret Key is required'),
accessToken: z.string().min(1, 'Access Token is required'),
accessTokenSecret: z.string().min(1, 'Access Token Secret is required')
});
export type Config = z.infer<typeof ConfigSchema>;
// Tool input schemas
export const PostTweetSchema = z.object({
text: z.string()
.min(1, 'Tweet text cannot be empty')
.max(280, 'Tweet cannot exceed 280 characters'),
reply_to_tweet_id: z.string().optional()
});
export const SearchTweetsSchema = z.object({
query: z.string().min(1, 'Search query cannot be empty'),
count: z.number()
.int('Count must be an integer')
.min(10, 'Minimum count is 10')
.max(100, 'Maximum count is 100')
});
export type PostTweetArgs = z.infer<typeof PostTweetSchema>;
export type SearchTweetsArgs = z.infer<typeof SearchTweetsSchema>;
// API Response types
export interface TweetMetrics {
likes: number;
retweets: number;
}
export interface PostedTweet {
id: string;
text: string;
}
export interface Tweet {
id: string;
text: string;
authorId: string;
metrics: TweetMetrics;
createdAt: string;
}
export interface TwitterUser {
id: string;
username: string;
}
// Error types
export class TwitterError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly status?: number
) {
super(message);
this.name = 'TwitterError';
}
static isRateLimit(error: unknown): error is TwitterError {
return error instanceof TwitterError && error.code === 'rate_limit_exceeded';
}
}
// Response formatter types
export interface FormattedTweet {
position: number;
author: {
username: string;
};
content: string;
metrics: TweetMetrics;
url: string;
}
export interface SearchResponse {
query: string;
count: number;
tweets: FormattedTweet[];
}
```
--------------------------------------------------------------------------------
/src/twitter-api.ts:
--------------------------------------------------------------------------------
```typescript
import { TwitterApi } from 'twitter-api-v2';
import { Config, TwitterError, Tweet, TwitterUser, PostedTweet } from './types.js';
export class TwitterClient {
private client: TwitterApi;
private rateLimitMap = new Map<string, number>();
constructor(config: Config) {
this.client = new TwitterApi({
appKey: config.apiKey,
appSecret: config.apiSecretKey,
accessToken: config.accessToken,
accessSecret: config.accessTokenSecret,
});
console.error('Twitter API client initialized');
}
async postTweet(text: string, replyToTweetId?: string): Promise<PostedTweet> {
try {
const endpoint = 'tweets/create';
await this.checkRateLimit(endpoint);
const tweetOptions: any = { text };
if (replyToTweetId) {
tweetOptions.reply = { in_reply_to_tweet_id: replyToTweetId };
}
const response = await this.client.v2.tweet(tweetOptions);
console.error(`Tweet posted successfully with ID: ${response.data.id}${replyToTweetId ? ` (reply to ${replyToTweetId})` : ''}`);
return {
id: response.data.id,
text: response.data.text
};
} catch (error) {
this.handleApiError(error);
}
}
async searchTweets(query: string, count: number): Promise<{ tweets: Tweet[], users: TwitterUser[] }> {
try {
const endpoint = 'tweets/search';
await this.checkRateLimit(endpoint);
const response = await this.client.v2.search(query, {
max_results: count,
expansions: ['author_id'],
'tweet.fields': ['public_metrics', 'created_at'],
'user.fields': ['username', 'name', 'verified']
});
console.error(`Fetched ${response.tweets.length} tweets for query: "${query}"`);
const tweets = response.tweets.map(tweet => ({
id: tweet.id,
text: tweet.text,
authorId: tweet.author_id ?? '',
metrics: {
likes: tweet.public_metrics?.like_count ?? 0,
retweets: tweet.public_metrics?.retweet_count ?? 0,
replies: tweet.public_metrics?.reply_count ?? 0,
quotes: tweet.public_metrics?.quote_count ?? 0
},
createdAt: tweet.created_at ?? ''
}));
const users = response.includes.users.map(user => ({
id: user.id,
username: user.username,
name: user.name,
verified: user.verified ?? false
}));
return { tweets, users };
} catch (error) {
this.handleApiError(error);
}
}
private async checkRateLimit(endpoint: string): Promise<void> {
const lastRequest = this.rateLimitMap.get(endpoint);
if (lastRequest) {
const timeSinceLastRequest = Date.now() - lastRequest;
if (timeSinceLastRequest < 1000) { // Basic rate limiting
throw new TwitterError(
'Rate limit exceeded',
'rate_limit_exceeded',
429
);
}
}
this.rateLimitMap.set(endpoint, Date.now());
}
private handleApiError(error: unknown): never {
if (error instanceof TwitterError) {
throw error;
}
// Handle twitter-api-v2 errors
const apiError = error as any;
if (apiError.code) {
throw new TwitterError(
apiError.message || 'Twitter API error',
apiError.code,
apiError.status
);
}
// Handle unexpected errors
console.error('Unexpected error in Twitter client:', error);
throw new TwitterError(
'An unexpected error occurred',
'internal_error',
500
);
}
}
```
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
```typescript
#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
ListToolsRequestSchema,
CallToolRequestSchema,
Tool,
ErrorCode,
McpError,
TextContent
} from '@modelcontextprotocol/sdk/types.js';
import { TwitterClient } from './twitter-api.js';
import { ResponseFormatter } from './formatter.js';
import {
Config, ConfigSchema,
PostTweetSchema, SearchTweetsSchema,
TwitterError
} from './types.js';
import dotenv from 'dotenv';
export class TwitterServer {
private server: Server;
private client: TwitterClient;
constructor(config: Config) {
// Validate config
const result = ConfigSchema.safeParse(config);
if (!result.success) {
throw new Error(`Invalid configuration: ${result.error.message}`);
}
this.client = new TwitterClient(config);
this.server = new Server({
name: 'twitter-mcp',
version: '1.0.0'
}, {
capabilities: {
tools: {}
}
});
this.setupHandlers();
}
private setupHandlers(): void {
// Error handler
this.server.onerror = (error) => {
console.error('[MCP Error]:', error);
};
// Graceful shutdown
process.on('SIGINT', async () => {
console.error('Shutting down server...');
await this.server.close();
process.exit(0);
});
// Register tool handlers
this.setupToolHandlers();
}
private setupToolHandlers(): void {
// List available tools
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'post_tweet',
description: 'Post a new tweet to Twitter',
inputSchema: {
type: 'object',
properties: {
text: {
type: 'string',
description: 'The content of your tweet',
maxLength: 280
},
reply_to_tweet_id: {
type: 'string',
description: 'Optional: ID of the tweet to reply to'
}
},
required: ['text']
}
} as Tool,
{
name: 'search_tweets',
description: 'Search for tweets on Twitter',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Search query'
},
count: {
type: 'number',
description: 'Number of tweets to return (10-100)',
minimum: 10,
maximum: 100
}
},
required: ['query', 'count']
}
} as Tool
]
}));
// Handle tool execution
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
console.error(`Tool called: ${name}`, args);
try {
switch (name) {
case 'post_tweet':
return await this.handlePostTweet(args);
case 'search_tweets':
return await this.handleSearchTweets(args);
default:
throw new McpError(
ErrorCode.MethodNotFound,
`Unknown tool: ${name}`
);
}
} catch (error) {
return this.handleError(error);
}
});
}
private async handlePostTweet(args: unknown) {
const result = PostTweetSchema.safeParse(args);
if (!result.success) {
throw new McpError(
ErrorCode.InvalidParams,
`Invalid parameters: ${result.error.message}`
);
}
const tweet = await this.client.postTweet(result.data.text, result.data.reply_to_tweet_id);
return {
content: [{
type: 'text',
text: `Tweet posted successfully!\nURL: https://twitter.com/status/${tweet.id}`
}] as TextContent[]
};
}
private async handleSearchTweets(args: unknown) {
const result = SearchTweetsSchema.safeParse(args);
if (!result.success) {
throw new McpError(
ErrorCode.InvalidParams,
`Invalid parameters: ${result.error.message}`
);
}
const { tweets, users } = await this.client.searchTweets(
result.data.query,
result.data.count
);
const formattedResponse = ResponseFormatter.formatSearchResponse(
result.data.query,
tweets,
users
);
return {
content: [{
type: 'text',
text: ResponseFormatter.toMcpResponse(formattedResponse)
}] as TextContent[]
};
}
private handleError(error: unknown) {
if (error instanceof McpError) {
throw error;
}
if (error instanceof TwitterError) {
if (TwitterError.isRateLimit(error)) {
return {
content: [{
type: 'text',
text: 'Rate limit exceeded. Please wait a moment before trying again.',
isError: true
}] as TextContent[]
};
}
return {
content: [{
type: 'text',
text: `Twitter API error: ${(error as TwitterError).message}`,
isError: true
}] as TextContent[]
};
}
console.error('Unexpected error:', error);
throw new McpError(
ErrorCode.InternalError,
'An unexpected error occurred'
);
}
async start(): Promise<void> {
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error('Twitter MCP server running on stdio');
}
}
// Start the server
dotenv.config();
const config = {
apiKey: process.env.API_KEY!,
apiSecretKey: process.env.API_SECRET_KEY!,
accessToken: process.env.ACCESS_TOKEN!,
accessTokenSecret: process.env.ACCESS_TOKEN_SECRET!
};
const server = new TwitterServer(config);
server.start().catch(error => {
console.error('Failed to start server:', error);
process.exit(1);
});
```