# 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:
--------------------------------------------------------------------------------
```
1 | node_modules/
2 | build/
3 | *.log
4 | .env*
5 | .vscode/
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | [](https://mseep.ai/app/enescinr-twitter-mcp)
2 |
3 | # Twitter MCP Server
4 |
5 | [](https://smithery.ai/server/@enescinar/twitter-mcp)
6 |
7 | This MCP server allows Clients to interact with Twitter, enabling posting tweets and searching Twitter.
8 |
9 | <a href="https://glama.ai/mcp/servers/dhsudtc7cd">
10 | <img width="380" height="200" src="https://glama.ai/mcp/servers/dhsudtc7cd/badge" alt="Twitter Server MCP server" />
11 | </a>
12 |
13 | ## Quick Start
14 |
15 | 1. Create a Twitter Developer account and get your API keys from [Twitter Developer Portal](https://developer.twitter.com/en/portal/dashboard)
16 |
17 | 2. Add this configuration to your Claude Desktop config file:
18 |
19 | **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
20 | **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
21 |
22 | ```json
23 | {
24 | "mcpServers": {
25 | "twitter-mcp": {
26 | "command": "npx",
27 | "args": ["-y", "@enescinar/twitter-mcp"],
28 | "env": {
29 | "API_KEY": "your_api_key_here",
30 | "API_SECRET_KEY": "your_api_secret_key_here",
31 | "ACCESS_TOKEN": "your_access_token_here",
32 | "ACCESS_TOKEN_SECRET": "your_access_token_secret_here"
33 | }
34 | }
35 | }
36 | }
37 | ```
38 |
39 | 3. Restart Claude Desktop
40 |
41 | That's it! Claude can now interact with Twitter through two tools:
42 |
43 | - `post_tweet`: Post a new tweet
44 | - `search_tweets`: Search for tweets
45 |
46 | ## Example Usage
47 |
48 | Try asking Claude:
49 | - "Can you post a tweet saying 'Hello from Claude!'"
50 | - "Can you search for tweets about Claude AI?"
51 |
52 | ## Troubleshooting
53 |
54 | Logs can be found at:
55 | - **Windows**: `%APPDATA%\Claude\logs\mcp-server-twitter.log`
56 | - **macOS**: `~/Library/Logs/Claude/mcp-server-twitter.log`
57 |
58 |
59 | ## Development
60 |
61 | If you want to contribute or run from source:
62 |
63 | 1. Clone the repository:
64 | ```bash
65 | git clone https://github.com/EnesCinr/twitter-mcp.git
66 | cd twitter-mcp
67 | ```
68 |
69 | 2. Install dependencies:
70 | ```bash
71 | npm install
72 | ```
73 |
74 | 3. Build:
75 | ```bash
76 | npm run build
77 | ```
78 |
79 | 4. Run:
80 | ```bash
81 | npm start
82 | ```
83 |
84 |
85 |
86 | ## Running evals
87 |
88 | 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).
89 |
90 | ```bash
91 | OPENAI_API_KEY=your-key npx mcp-eval src/evals/evals.ts src/index.ts
92 | ```
93 | ## License
94 |
95 | MIT
96 |
```
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "compilerOptions": {
3 | "target": "ES2022",
4 | "module": "Node16",
5 | "moduleResolution": "Node16",
6 | "outDir": "./build",
7 | "rootDir": "./src",
8 | "strict": true,
9 | "esModuleInterop": true,
10 | "skipLibCheck": true,
11 | "forceConsistentCasingInFileNames": true
12 | },
13 | "include": ["src/**/*"],
14 | "exclude": ["node_modules"]
15 | }
16 |
```
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: Publish to NPM
2 | on:
3 | release:
4 | types: [created]
5 | jobs:
6 | build:
7 | runs-on: ubuntu-latest
8 | steps:
9 | - name: Checkout
10 | uses: actions/checkout@v2
11 | with:
12 | fetch-depth: 0
13 | - name: Setup Node
14 | uses: actions/setup-node@v2
15 | with:
16 | node-version: '22'
17 | registry-url: 'https://registry.npmjs.org'
18 | - name: Install dependencies and build 🔧
19 | run: npm ci
20 | - name: Publish package on NPM 📦
21 | run: npm publish
22 | env:
23 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
24 |
```
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: Releases
2 | on:
3 | push:
4 | branches:
5 | - main
6 |
7 | jobs:
8 | changelog:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - uses: actions/checkout@v2
12 | - name: Conventional Changelog Action
13 | id: changelog
14 | uses: TriPSs/[email protected]
15 | with:
16 | github-token: ${{ secrets.PA_TOKEN }}
17 | version-file: './package.json,./package-lock.json'
18 | - name: create release
19 | uses: actions/create-release@v1
20 | if: ${{ steps.changelog.outputs.skipped == 'false' }}
21 | env:
22 | GITHUB_TOKEN: ${{ secrets.PA_TOKEN }}
23 | with:
24 | tag_name: ${{ steps.changelog.outputs.tag }}
25 | release_name: ${{ steps.changelog.outputs.tag }}
26 | body: ${{ steps.changelog.outputs.clean_changelog }}
27 |
```
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
```yaml
1 | # These are supported funding model platforms
2 |
3 | github: [EnesCinr]
4 | patreon: # Replace with a single Patreon username
5 | open_collective: # Replace with a single Open Collective username
6 | ko_fi: # Replace with a single Ko-fi username
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | liberapay: # Replace with a single Liberapay username
10 | issuehunt: # Replace with a single IssueHunt username
11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
12 | polar: # Replace with a single Polar username
13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
14 | thanks_dev: # Replace with a single thanks.dev username
15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
16 |
```
--------------------------------------------------------------------------------
/smithery.yaml:
--------------------------------------------------------------------------------
```yaml
1 | # Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml
2 |
3 | startCommand:
4 | type: stdio
5 | configSchema:
6 | # JSON Schema defining the configuration options for the MCP.
7 | type: object
8 | required:
9 | - apiKey
10 | - apiSecretKey
11 | - accessToken
12 | - accessTokenSecret
13 | properties:
14 | apiKey:
15 | type: string
16 | description: Twitter API key.
17 | apiSecretKey:
18 | type: string
19 | description: Twitter API secret key.
20 | accessToken:
21 | type: string
22 | description: Twitter access token.
23 | accessTokenSecret:
24 | type: string
25 | description: Twitter access token secret.
26 | commandFunction:
27 | # A function that produces the CLI command to start the MCP on stdio.
28 | |-
29 | 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
1 | //evals.ts
2 |
3 | import { EvalConfig } from 'mcp-evals';
4 | import { openai } from "@ai-sdk/openai";
5 | import { grade, EvalFunction } from "mcp-evals";
6 |
7 | const post_tweetEval: EvalFunction = {
8 | name: "post_tweet Evaluation",
9 | description: "Evaluates the functionality of posting a new tweet to Twitter",
10 | run: async () => {
11 | const result = await grade(openai("gpt-4"), "Please post a tweet saying: 'Excited to announce our new feature launch! #NewFeature'");
12 | return JSON.parse(result);
13 | }
14 | };
15 |
16 | const search_tweetsEval: EvalFunction = {
17 | name: 'search_tweets Tool Evaluation',
18 | description: 'Evaluates the search_tweets tool functionality',
19 | run: async () => {
20 | const result = await grade(openai("gpt-4"), "Please search for tweets about '#AI' with 15 results.");
21 | return JSON.parse(result);
22 | }
23 | };
24 |
25 | const config: EvalConfig = {
26 | model: openai("gpt-4"),
27 | evals: [post_tweetEval, search_tweetsEval]
28 | };
29 |
30 | export default config;
31 |
32 | export const evals = [post_tweetEval, search_tweetsEval];
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "@enescinar/twitter-mcp",
3 | "version": "0.2.0",
4 | "description": "A Model Context Protocol server allows to interact with Twitter, enabling posting tweets and searching Twitter.",
5 | "type": "module",
6 | "main": "build/index.js",
7 | "bin": {
8 | "twitter-server": "./build/index.js"
9 | },
10 | "files": [
11 | "build",
12 | "README.md"
13 | ],
14 | "scripts": {
15 | "build": "tsc",
16 | "start": "node build/index.js",
17 | "prepublishOnly": "npm run build"
18 | },
19 | "keywords": [
20 | "mcp",
21 | "modelcontextprotocol",
22 | "server",
23 | "twitter",
24 | "claude"
25 | ],
26 | "author": "Enes Cinar",
27 | "license": "MIT",
28 | "dependencies": {
29 | "@modelcontextprotocol/sdk": "0.6.0",
30 | "dotenv": "^16.4.7",
31 | "mcp-evals": "^1.0.18",
32 | "twitter-api-v2": "^1.18.2",
33 | "zod": "^3.24.0"
34 | },
35 | "devDependencies": {
36 | "@types/node": "^20.11.24",
37 | "typescript": "^5.3.3"
38 | },
39 | "repository": {
40 | "type": "git",
41 | "url": "git+https://github.com/EnesCinr/twitter-mcp.git"
42 | },
43 | "bugs": {
44 | "url": "https://github.com/EnesCinr/twitter-mcp/issues"
45 | },
46 | "homepage": "https://github.com/EnesCinr/twitter-mcp#readme"
47 | }
48 |
```
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
```dockerfile
1 | # Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile
2 | # Use a Node.js image for building the server
3 | FROM node:18-alpine AS builder
4 |
5 | # Set the working directory in the container
6 | WORKDIR /app
7 |
8 | # Copy package.json and package-lock.json to the working directory
9 | COPY package.json package-lock.json ./
10 |
11 | # Install dependencies
12 | RUN npm install
13 |
14 | # Copy the entire source code into the working directory
15 | COPY . .
16 |
17 | # Build the TypeScript files
18 | RUN npm run build
19 |
20 | # Use a smaller Node.js image for the runtime
21 | FROM node:18-slim
22 |
23 | # Set the working directory in the runtime image
24 | WORKDIR /app
25 |
26 | # Copy the build files from the builder image
27 | COPY --from=builder /app/build ./build
28 |
29 | # Copy package.json and package-lock.json for production install
30 | COPY package.json package-lock.json ./
31 |
32 | # Install only production dependencies
33 | RUN npm install --omit=dev
34 |
35 | # Set environment variables for Twitter API
36 | ENV API_KEY=your_api_key_here
37 | ENV API_SECRET_KEY=your_api_secret_key_here
38 | ENV ACCESS_TOKEN=your_access_token_here
39 | ENV ACCESS_TOKEN_SECRET=your_access_token_secret_here
40 |
41 | # Start the server
42 | CMD ["node", "build/index.js"]
```
--------------------------------------------------------------------------------
/src/formatter.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { FormattedTweet, Tweet, TwitterUser, SearchResponse } from './types.js';
2 |
3 | export class ResponseFormatter {
4 | static formatTweet(tweet: Tweet, user: TwitterUser, position: number): FormattedTweet {
5 | return {
6 | position,
7 | author: {
8 | username: user.username
9 | },
10 | content: tweet.text,
11 | metrics: tweet.metrics,
12 | url: `https://twitter.com/${user.username}/status/${tweet.id}`
13 | };
14 | }
15 |
16 | static formatSearchResponse(
17 | query: string,
18 | tweets: Tweet[],
19 | users: TwitterUser[]
20 | ): SearchResponse {
21 | const userMap = new Map(users.map(user => [user.id, user]));
22 |
23 | const formattedTweets = tweets
24 | .map((tweet, index) => {
25 | const user = userMap.get(tweet.authorId);
26 | if (!user) return null;
27 |
28 | return this.formatTweet(tweet, user, index + 1);
29 | })
30 | .filter((tweet): tweet is FormattedTweet => tweet !== null);
31 |
32 | return {
33 | query,
34 | count: formattedTweets.length,
35 | tweets: formattedTweets
36 | };
37 | }
38 |
39 | static toMcpResponse(response: SearchResponse): string {
40 | const header = [
41 | 'TWITTER SEARCH RESULTS',
42 | `Query: "${response.query}"`,
43 | `Found ${response.count} tweets`,
44 | '='
45 | ].join('\n');
46 |
47 | if (response.count === 0) {
48 | return header + '\nNo tweets found matching your query.';
49 | }
50 |
51 | const tweetBlocks = response.tweets.map(tweet => [
52 | `Tweet #${tweet.position}`,
53 | `From: @${tweet.author.username}`,
54 | `Content: ${tweet.content}`,
55 | `Metrics: ${tweet.metrics.likes} likes, ${tweet.metrics.retweets} retweets`,
56 | `URL: ${tweet.url}`,
57 | '='
58 | ].join('\n'));
59 |
60 | return [header, ...tweetBlocks].join('\n\n');
61 | }
62 | }
```
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { z } from 'zod';
2 |
3 | // Configuration schema with validation
4 | export const ConfigSchema = z.object({
5 | apiKey: z.string().min(1, 'API Key is required'),
6 | apiSecretKey: z.string().min(1, 'API Secret Key is required'),
7 | accessToken: z.string().min(1, 'Access Token is required'),
8 | accessTokenSecret: z.string().min(1, 'Access Token Secret is required')
9 | });
10 |
11 | export type Config = z.infer<typeof ConfigSchema>;
12 |
13 | // Tool input schemas
14 | export const PostTweetSchema = z.object({
15 | text: z.string()
16 | .min(1, 'Tweet text cannot be empty')
17 | .max(280, 'Tweet cannot exceed 280 characters'),
18 | reply_to_tweet_id: z.string().optional()
19 | });
20 |
21 | export const SearchTweetsSchema = z.object({
22 | query: z.string().min(1, 'Search query cannot be empty'),
23 | count: z.number()
24 | .int('Count must be an integer')
25 | .min(10, 'Minimum count is 10')
26 | .max(100, 'Maximum count is 100')
27 | });
28 |
29 | export type PostTweetArgs = z.infer<typeof PostTweetSchema>;
30 | export type SearchTweetsArgs = z.infer<typeof SearchTweetsSchema>;
31 |
32 | // API Response types
33 | export interface TweetMetrics {
34 | likes: number;
35 | retweets: number;
36 | }
37 |
38 | export interface PostedTweet {
39 | id: string;
40 | text: string;
41 | }
42 |
43 | export interface Tweet {
44 | id: string;
45 | text: string;
46 | authorId: string;
47 | metrics: TweetMetrics;
48 | createdAt: string;
49 | }
50 |
51 | export interface TwitterUser {
52 | id: string;
53 | username: string;
54 | }
55 |
56 | // Error types
57 | export class TwitterError extends Error {
58 | constructor(
59 | message: string,
60 | public readonly code: string,
61 | public readonly status?: number
62 | ) {
63 | super(message);
64 | this.name = 'TwitterError';
65 | }
66 |
67 | static isRateLimit(error: unknown): error is TwitterError {
68 | return error instanceof TwitterError && error.code === 'rate_limit_exceeded';
69 | }
70 | }
71 |
72 | // Response formatter types
73 | export interface FormattedTweet {
74 | position: number;
75 | author: {
76 | username: string;
77 | };
78 | content: string;
79 | metrics: TweetMetrics;
80 | url: string;
81 | }
82 |
83 | export interface SearchResponse {
84 | query: string;
85 | count: number;
86 | tweets: FormattedTweet[];
87 | }
```
--------------------------------------------------------------------------------
/src/twitter-api.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { TwitterApi } from 'twitter-api-v2';
2 | import { Config, TwitterError, Tweet, TwitterUser, PostedTweet } from './types.js';
3 |
4 | export class TwitterClient {
5 | private client: TwitterApi;
6 | private rateLimitMap = new Map<string, number>();
7 |
8 | constructor(config: Config) {
9 | this.client = new TwitterApi({
10 | appKey: config.apiKey,
11 | appSecret: config.apiSecretKey,
12 | accessToken: config.accessToken,
13 | accessSecret: config.accessTokenSecret,
14 | });
15 |
16 | console.error('Twitter API client initialized');
17 | }
18 |
19 | async postTweet(text: string, replyToTweetId?: string): Promise<PostedTweet> {
20 | try {
21 | const endpoint = 'tweets/create';
22 | await this.checkRateLimit(endpoint);
23 |
24 | const tweetOptions: any = { text };
25 | if (replyToTweetId) {
26 | tweetOptions.reply = { in_reply_to_tweet_id: replyToTweetId };
27 | }
28 |
29 | const response = await this.client.v2.tweet(tweetOptions);
30 |
31 | console.error(`Tweet posted successfully with ID: ${response.data.id}${replyToTweetId ? ` (reply to ${replyToTweetId})` : ''}`);
32 |
33 | return {
34 | id: response.data.id,
35 | text: response.data.text
36 | };
37 | } catch (error) {
38 | this.handleApiError(error);
39 | }
40 | }
41 |
42 | async searchTweets(query: string, count: number): Promise<{ tweets: Tweet[], users: TwitterUser[] }> {
43 | try {
44 | const endpoint = 'tweets/search';
45 | await this.checkRateLimit(endpoint);
46 |
47 | const response = await this.client.v2.search(query, {
48 | max_results: count,
49 | expansions: ['author_id'],
50 | 'tweet.fields': ['public_metrics', 'created_at'],
51 | 'user.fields': ['username', 'name', 'verified']
52 | });
53 |
54 | console.error(`Fetched ${response.tweets.length} tweets for query: "${query}"`);
55 |
56 | const tweets = response.tweets.map(tweet => ({
57 | id: tweet.id,
58 | text: tweet.text,
59 | authorId: tweet.author_id ?? '',
60 | metrics: {
61 | likes: tweet.public_metrics?.like_count ?? 0,
62 | retweets: tweet.public_metrics?.retweet_count ?? 0,
63 | replies: tweet.public_metrics?.reply_count ?? 0,
64 | quotes: tweet.public_metrics?.quote_count ?? 0
65 | },
66 | createdAt: tweet.created_at ?? ''
67 | }));
68 |
69 | const users = response.includes.users.map(user => ({
70 | id: user.id,
71 | username: user.username,
72 | name: user.name,
73 | verified: user.verified ?? false
74 | }));
75 |
76 | return { tweets, users };
77 | } catch (error) {
78 | this.handleApiError(error);
79 | }
80 | }
81 |
82 | private async checkRateLimit(endpoint: string): Promise<void> {
83 | const lastRequest = this.rateLimitMap.get(endpoint);
84 | if (lastRequest) {
85 | const timeSinceLastRequest = Date.now() - lastRequest;
86 | if (timeSinceLastRequest < 1000) { // Basic rate limiting
87 | throw new TwitterError(
88 | 'Rate limit exceeded',
89 | 'rate_limit_exceeded',
90 | 429
91 | );
92 | }
93 | }
94 | this.rateLimitMap.set(endpoint, Date.now());
95 | }
96 |
97 | private handleApiError(error: unknown): never {
98 | if (error instanceof TwitterError) {
99 | throw error;
100 | }
101 |
102 | // Handle twitter-api-v2 errors
103 | const apiError = error as any;
104 | if (apiError.code) {
105 | throw new TwitterError(
106 | apiError.message || 'Twitter API error',
107 | apiError.code,
108 | apiError.status
109 | );
110 | }
111 |
112 | // Handle unexpected errors
113 | console.error('Unexpected error in Twitter client:', error);
114 | throw new TwitterError(
115 | 'An unexpected error occurred',
116 | 'internal_error',
117 | 500
118 | );
119 | }
120 | }
```
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | #!/usr/bin/env node
2 | import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4 | import {
5 | ListToolsRequestSchema,
6 | CallToolRequestSchema,
7 | Tool,
8 | ErrorCode,
9 | McpError,
10 | TextContent
11 | } from '@modelcontextprotocol/sdk/types.js';
12 | import { TwitterClient } from './twitter-api.js';
13 | import { ResponseFormatter } from './formatter.js';
14 | import {
15 | Config, ConfigSchema,
16 | PostTweetSchema, SearchTweetsSchema,
17 | TwitterError
18 | } from './types.js';
19 | import dotenv from 'dotenv';
20 |
21 | export class TwitterServer {
22 | private server: Server;
23 | private client: TwitterClient;
24 |
25 | constructor(config: Config) {
26 | // Validate config
27 | const result = ConfigSchema.safeParse(config);
28 | if (!result.success) {
29 | throw new Error(`Invalid configuration: ${result.error.message}`);
30 | }
31 |
32 | this.client = new TwitterClient(config);
33 | this.server = new Server({
34 | name: 'twitter-mcp',
35 | version: '1.0.0'
36 | }, {
37 | capabilities: {
38 | tools: {}
39 | }
40 | });
41 |
42 | this.setupHandlers();
43 | }
44 |
45 | private setupHandlers(): void {
46 | // Error handler
47 | this.server.onerror = (error) => {
48 | console.error('[MCP Error]:', error);
49 | };
50 |
51 | // Graceful shutdown
52 | process.on('SIGINT', async () => {
53 | console.error('Shutting down server...');
54 | await this.server.close();
55 | process.exit(0);
56 | });
57 |
58 | // Register tool handlers
59 | this.setupToolHandlers();
60 | }
61 |
62 | private setupToolHandlers(): void {
63 | // List available tools
64 | this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
65 | tools: [
66 | {
67 | name: 'post_tweet',
68 | description: 'Post a new tweet to Twitter',
69 | inputSchema: {
70 | type: 'object',
71 | properties: {
72 | text: {
73 | type: 'string',
74 | description: 'The content of your tweet',
75 | maxLength: 280
76 | },
77 | reply_to_tweet_id: {
78 | type: 'string',
79 | description: 'Optional: ID of the tweet to reply to'
80 | }
81 | },
82 | required: ['text']
83 | }
84 | } as Tool,
85 | {
86 | name: 'search_tweets',
87 | description: 'Search for tweets on Twitter',
88 | inputSchema: {
89 | type: 'object',
90 | properties: {
91 | query: {
92 | type: 'string',
93 | description: 'Search query'
94 | },
95 | count: {
96 | type: 'number',
97 | description: 'Number of tweets to return (10-100)',
98 | minimum: 10,
99 | maximum: 100
100 | }
101 | },
102 | required: ['query', 'count']
103 | }
104 | } as Tool
105 | ]
106 | }));
107 |
108 | // Handle tool execution
109 | this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
110 | const { name, arguments: args } = request.params;
111 | console.error(`Tool called: ${name}`, args);
112 |
113 | try {
114 | switch (name) {
115 | case 'post_tweet':
116 | return await this.handlePostTweet(args);
117 | case 'search_tweets':
118 | return await this.handleSearchTweets(args);
119 | default:
120 | throw new McpError(
121 | ErrorCode.MethodNotFound,
122 | `Unknown tool: ${name}`
123 | );
124 | }
125 | } catch (error) {
126 | return this.handleError(error);
127 | }
128 | });
129 | }
130 |
131 | private async handlePostTweet(args: unknown) {
132 | const result = PostTweetSchema.safeParse(args);
133 | if (!result.success) {
134 | throw new McpError(
135 | ErrorCode.InvalidParams,
136 | `Invalid parameters: ${result.error.message}`
137 | );
138 | }
139 |
140 | const tweet = await this.client.postTweet(result.data.text, result.data.reply_to_tweet_id);
141 | return {
142 | content: [{
143 | type: 'text',
144 | text: `Tweet posted successfully!\nURL: https://twitter.com/status/${tweet.id}`
145 | }] as TextContent[]
146 | };
147 | }
148 |
149 | private async handleSearchTweets(args: unknown) {
150 | const result = SearchTweetsSchema.safeParse(args);
151 | if (!result.success) {
152 | throw new McpError(
153 | ErrorCode.InvalidParams,
154 | `Invalid parameters: ${result.error.message}`
155 | );
156 | }
157 |
158 | const { tweets, users } = await this.client.searchTweets(
159 | result.data.query,
160 | result.data.count
161 | );
162 |
163 | const formattedResponse = ResponseFormatter.formatSearchResponse(
164 | result.data.query,
165 | tweets,
166 | users
167 | );
168 |
169 | return {
170 | content: [{
171 | type: 'text',
172 | text: ResponseFormatter.toMcpResponse(formattedResponse)
173 | }] as TextContent[]
174 | };
175 | }
176 |
177 | private handleError(error: unknown) {
178 | if (error instanceof McpError) {
179 | throw error;
180 | }
181 |
182 | if (error instanceof TwitterError) {
183 | if (TwitterError.isRateLimit(error)) {
184 | return {
185 | content: [{
186 | type: 'text',
187 | text: 'Rate limit exceeded. Please wait a moment before trying again.',
188 | isError: true
189 | }] as TextContent[]
190 | };
191 | }
192 |
193 | return {
194 | content: [{
195 | type: 'text',
196 | text: `Twitter API error: ${(error as TwitterError).message}`,
197 | isError: true
198 | }] as TextContent[]
199 | };
200 | }
201 |
202 | console.error('Unexpected error:', error);
203 | throw new McpError(
204 | ErrorCode.InternalError,
205 | 'An unexpected error occurred'
206 | );
207 | }
208 |
209 | async start(): Promise<void> {
210 | const transport = new StdioServerTransport();
211 | await this.server.connect(transport);
212 | console.error('Twitter MCP server running on stdio');
213 | }
214 | }
215 |
216 | // Start the server
217 | dotenv.config();
218 |
219 | const config = {
220 | apiKey: process.env.API_KEY!,
221 | apiSecretKey: process.env.API_SECRET_KEY!,
222 | accessToken: process.env.ACCESS_TOKEN!,
223 | accessTokenSecret: process.env.ACCESS_TOKEN_SECRET!
224 | };
225 |
226 | const server = new TwitterServer(config);
227 | server.start().catch(error => {
228 | console.error('Failed to start server:', error);
229 | process.exit(1);
230 | });
```