#
tokens: 4836/50000 7/7 files
lines: on (toggle) GitHub
raw markdown copy reset
# Directory Structure

```
├── .gitignore
├── Dockerfile
├── LICENSE
├── pyproject.toml
├── README.md
├── smithery.yaml
└── src
    └── mcp_twikit
        ├── __init__.py
        └── twitter.py
```

# Files

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
1 | .venv/
2 | 
```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
  1 | # MCP-Twikit
  2 | 
  3 | [![smithery badge](https://smithery.ai/badge/mcp-twikit)](https://smithery.ai/server/mcp-twikit)
  4 | A Model Context Protocol (MCP) server for interacting with Twitter.
  5 | 
  6 | <a href="https://glama.ai/mcp/servers/49i9dd08w8"><img width="380" height="200" src="https://glama.ai/mcp/servers/49i9dd08w8/badge" alt="mcp-twikit MCP server" /></a>
  7 | 
  8 | ## Installation
  9 | 
 10 | ### Installing via Smithery
 11 | 
 12 | To install Twikit Twitter Search for Claude Desktop automatically via [Smithery](https://smithery.ai/server/mcp-twikit):
 13 | 
 14 | ```bash
 15 | npx -y @smithery/cli install mcp-twikit --client claude
 16 | ```
 17 | 
 18 | ### Manual Installation
 19 | ```json
 20 | {
 21 |     "mcpServer": {
 22 |         "command": "uvx",
 23 |         "args": ["--from", "git+https://github.com/adhikasp/mcp-twikit", "mcp-twikit"],
 24 |         "env": {
 25 |             "TWITTER_USERNAME": "@example",
 26 |             "TWITTER_EMAIL": "[email protected]",
 27 |             "TWITTER_PASSWORD": "secret",
 28 |         }
 29 |     }
 30 | }
 31 | ```
 32 | 
 33 | ## Example usage
 34 | 
 35 | Using [mcp-client-cli](https://github.com/adhikasp/mcp-client-cli)
 36 | 
 37 | 
 38 | ### Comparing sentiments across different twitter accounts
 39 | 
 40 | ```
 41 | $ llm compare 20 latest tweet directed @IndiHomeCare, @di_cbn, @BiznetHome, @ID_MyRepublic. What are people sentiment to the product? Do 1 search for each account
 42 | 
 43 | I'll help you analyze the latest tweets for these internet service providers in Indonesia to understand the public sentiment. I'll use the search_twitter function for each account.
 44 | 
 45 | Tool Calls:
 46 |   search_twitter
 47 |   Args:
 48 |     query: to:IndiHomeCare
 49 |     count: 20
 50 |     sort_by: Latest
 51 | 
 52 | Tool Calls:
 53 |   search_twitter
 54 |   Args:
 55 |     query: to:di_cbn
 56 |     count: 20
 57 |     sort_by: Latest
 58 | 
 59 | Tool Calls:
 60 |   search_twitter
 61 |   Args:
 62 |     query: to:BiznetHome
 63 |     count: 20
 64 |     sort_by: Latest
 65 | 
 66 | Tool Calls:
 67 |   search_twitter
 68 |   Args:
 69 |     query: to:ID_MyRepublic
 70 |     count: 20
 71 |     sort_by: Latest
 72 | 
 73 | Sentiment Analysis Summary:
 74 | 
 75 | 1. IndiHomeCare:
 76 | - Predominantly Negative Sentiment
 77 | - Key Issues:
 78 |   - Unstable internet connection
 79 |   - Slow response times
 80 |   - Intermittent service disruptions
 81 |   - Long waiting times for customer support
 82 | - Specific Complaints:
 83 |   - Network instability
 84 |   - Lagging internet
 85 |   - Unresolved technical issues
 86 |   - Lack of timely communication
 87 | 
 88 | 2. CBN (di_cbn):
 89 | - Mostly Negative Sentiment
 90 | - Key Issues:
 91 |   - Prolonged internet outages (some reporting 1-2 days of no service)
 92 |   - Slow customer support response
 93 |   - Lack of clear communication about service disruptions
 94 | - Specific Complaints:
 95 |   - LOS (Line of Sight) issues
 96 |   - No timely resolution of technical problems
 97 |   - Delayed email and support responses
 98 | 
 99 | 3. BiznetHome:
100 | - Strongly Negative Sentiment
101 | - Key Issues:
102 |   - Frequent network interruptions
103 |   - Slow internet speeds
104 |   - Poor customer support responsiveness
105 | - Specific Complaints:
106 |   - Packet loss
107 |   - Inconsistent connection
108 |   - Long download times
109 |   - Unresolved technical issues
110 | 
111 | 4. MyRepublic (ID_MyRepublic):
112 | - Extremely Negative Sentiment
113 | - Key Issues:
114 |   - Severe and prolonged service disruptions
115 |   - Complete internet outages
116 |   - Inability to access online services and games
117 |   - Extremely poor customer support
118 | - Specific Complaints:
119 |   - Multi-day service interruptions
120 |   - Inability to access websites and online games
121 |   - Missed technician appointments
122 |   - Lack of compensation or clear communication
123 | 
124 | Overall Sentiment Ranking (from Bad to Worst):
125 | 1. IndiHomeCare (Bad)
126 | 2. CBN (Worse)
127 | 3. BiznetHome (Very Bad)
128 | 4. MyRepublic (Extremely Bad)
129 | 
130 | Common Themes Across Providers:
131 | - Poor network stability
132 | - Slow customer support response
133 | - Lack of transparent communication
134 | - Prolonged service interruptions
135 | - Unresolved technical issues
136 | 
137 | Recommendation: These Indonesian internet service providers are experiencing significant customer satisfaction issues. Customers are frustrated with unreliable services, poor technical support, and lack of timely problem resolution.
138 | ```
139 | 
140 | ### Getting tweets from your home timeline
141 | 
142 | ```
143 | $ llm what is happening on my twitter timeline?
144 | 
145 | I'll help you check your Twitter timeline. I'll retrieve the latest tweets from your timeline:
146 | 
147 | Tool Calls:
148 |   get_timeline
149 |   Args:
150 | 
151 | I'll summarize the key highlights from your Twitter timeline:
152 | 
153 | 1. Professional/Tech Highlights:
154 | - @ibamarief is preparing to onboard their first intern, noting it's a significant responsibility
155 | - @papanberjalan shared a critique about web developers not understanding computer performance
156 | - @pathofexile is running a Twitch Drops promotion for their game
157 | 
158 | 2. Notable Tweets:
159 | - @elonmusk posted a cryptic "Hmm"
160 | - @realDonaldTrump discussed eliminating Daylight Saving Time
161 | - @blinkist shared an article about billionaires reading up to 50 books per year
162 | 
163 | 3. Miscellaneous Interesting Tweets:
164 | - @nealtaparia shared a thread about 11 companies built around a single product
165 | - @Rixhabh__ posted about creative and iconic ads
166 | - Several tweets in Indonesian covering various topics from personal stories to social issues
167 | 
168 | Would you like me to elaborate on any of these tweets or provide more context about any specific post?
169 | ```
170 | 
```

--------------------------------------------------------------------------------
/src/mcp_twikit/__init__.py:
--------------------------------------------------------------------------------

```python
1 | 
```

--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------

```toml
 1 | [build-system]
 2 | requires = ["hatchling"]
 3 | build-backend = "hatchling.build"
 4 | 
 5 | [project]
 6 | name = "mcp-twikit"
 7 | version = "0.1.0"
 8 | description = "Twitter search tool for FastMCP using Twikit"
 9 | readme = "README.md"
10 | requires-python = ">=3.7"
11 | license = "MIT"
12 | authors = [
13 |     { name = "Adhika Setya Pramudita", email = "[email protected]" }
14 | ]
15 | dependencies = [
16 |     "fastmcp",
17 |     "twikit",
18 |     "requests",
19 | ]
20 | 
21 | [project.urls]
22 | Homepage = "https://github.com/adhikasp/mcp-twikit"
23 | 
24 | [project.scripts]
25 | mcp-twikit = "mcp_twikit.twitter:mcp.run"
```

--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------

```dockerfile
 1 | # Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile
 2 | # Use a Python image
 3 | FROM python:3.8-slim
 4 | 
 5 | # Set working directory
 6 | WORKDIR /app
 7 | 
 8 | # Copy the project files
 9 | COPY . /app
10 | 
11 | # Install the project's dependencies
12 | RUN pip install --no-cache-dir hatchling
13 | RUN pip install --no-cache-dir .
14 | 
15 | # Set environment variables for Twitter authentication
16 | # These should be provided at runtime for security purposes
17 | ENV TWITTER_USERNAME "@example"
18 | ENV TWITTER_EMAIL "[email protected]"
19 | ENV TWITTER_PASSWORD "secret"
20 | 
21 | # Set the entrypoint command to run the MCP server
22 | ENTRYPOINT ["mcp-twikit"]
23 | 
```

--------------------------------------------------------------------------------
/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 |       - twitterUsername
10 |       - twitterEmail
11 |       - twitterPassword
12 |     properties:
13 |       twitterUsername:
14 |         type: string
15 |         description: Your Twitter username.
16 |       twitterEmail:
17 |         type: string
18 |         description: Your Twitter email.
19 |       twitterPassword:
20 |         type: string
21 |         description: Your Twitter password.
22 |   commandFunction:
23 |     # A function that produces the CLI command to start the MCP on stdio.
24 |     |-
25 |     (config) => ({ command: 'mcp-twikit', env: { TWITTER_USERNAME: config.twitterUsername, TWITTER_EMAIL: config.twitterEmail, TWITTER_PASSWORD: config.twitterPassword } })
```

--------------------------------------------------------------------------------
/src/mcp_twikit/twitter.py:
--------------------------------------------------------------------------------

```python
  1 | from fastmcp import FastMCP, Context
  2 | import twikit
  3 | import os
  4 | from pathlib import Path
  5 | import logging
  6 | from typing import Optional, List
  7 | import time
  8 | 
  9 | # Create an MCP server
 10 | mcp = FastMCP("mcp-twikit")
 11 | logger = logging.getLogger(__name__)
 12 | httpx_logger = logging.getLogger("httpx")
 13 | httpx_logger.setLevel(logging.WARNING)
 14 | 
 15 | USERNAME = os.getenv('TWITTER_USERNAME')
 16 | EMAIL = os.getenv('TWITTER_EMAIL')
 17 | PASSWORD = os.getenv('TWITTER_PASSWORD')
 18 | USER_AGENT = os.getenv('USER_AGENT')
 19 | COOKIES_PATH = Path.home() / '.mcp-twikit' / 'cookies.json'
 20 | 
 21 | # Rate limit tracking
 22 | RATE_LIMITS = {}
 23 | RATE_LIMIT_WINDOW = 15 * 60  # 15 minutes in seconds
 24 | 
 25 | async def get_twitter_client() -> twikit.Client:
 26 |     """Initialize and return an authenticated Twitter client."""
 27 |     client = twikit.Client('en-US', user_agent=USER_AGENT)
 28 | 
 29 |     if COOKIES_PATH.exists():
 30 |         client.load_cookies(COOKIES_PATH)
 31 |     else:
 32 |         try:
 33 |             await client.login(
 34 |                 auth_info_1=USERNAME,
 35 |                 auth_info_2=EMAIL,
 36 |                 password=PASSWORD
 37 |             )
 38 |         except Exception as e:
 39 |             logger.error(f"Failed to login: {e}")
 40 |             raise
 41 |         COOKIES_PATH.parent.mkdir(parents=True, exist_ok=True)
 42 |         client.save_cookies(COOKIES_PATH)
 43 | 
 44 |     return client
 45 | 
 46 | def check_rate_limit(endpoint: str) -> bool:
 47 |     """Check if we're within rate limits for a given endpoint."""
 48 |     now = time.time()
 49 |     if endpoint not in RATE_LIMITS:
 50 |         RATE_LIMITS[endpoint] = []
 51 | 
 52 |     # Remove old timestamps
 53 |     RATE_LIMITS[endpoint] = [t for t in RATE_LIMITS[endpoint] if now - t < RATE_LIMIT_WINDOW]
 54 | 
 55 |     # Check limits based on endpoint
 56 |     if endpoint == 'tweet':
 57 |         return len(RATE_LIMITS[endpoint]) < 300  # 300 tweets per 15 minutes
 58 |     elif endpoint == 'dm':
 59 |         return len(RATE_LIMITS[endpoint]) < 1000  # 1000 DMs per 15 minutes
 60 |     return True
 61 | 
 62 | # Existing search and read tools
 63 | @mcp.tool()
 64 | async def search_twitter(query: str, sort_by: str = 'Top', count: int = 10, ctx: Context = None) -> str:
 65 |     """Search twitter with a query. Sort by 'Top' or 'Latest'"""
 66 |     try:
 67 |         client = await get_twitter_client()
 68 |         tweets = await client.search_tweet(query, product=sort_by, count=count)
 69 |         return convert_tweets_to_markdown(tweets)
 70 |     except Exception as e:
 71 |         logger.error(f"Failed to search tweets: {e}")
 72 |         return f"Failed to search tweets: {e}"
 73 | 
 74 | @mcp.tool()
 75 | async def get_user_tweets(username: str, tweet_type: str = 'Tweets', count: int = 10, ctx: Context = None) -> str:
 76 |     """Get tweets from a specific user's timeline."""
 77 |     try:
 78 |         client = await get_twitter_client()
 79 |         username = username.lstrip('@')
 80 |         user = await client.get_user_by_screen_name(username)
 81 |         if not user:
 82 |             return f"Could not find user {username}"
 83 | 
 84 |         tweets = await client.get_user_tweets(
 85 |             user_id=user.id,
 86 |             tweet_type=tweet_type,
 87 |             count=count
 88 |         )
 89 |         return convert_tweets_to_markdown(tweets)
 90 |     except Exception as e:
 91 |         logger.error(f"Failed to get user tweets: {e}")
 92 |         return f"Failed to get user tweets: {e}"
 93 | 
 94 | @mcp.tool()
 95 | async def get_timeline(count: int = 20) -> str:
 96 |     """Get tweets from your home timeline (For You)."""
 97 |     try:
 98 |         client = await get_twitter_client()
 99 |         tweets = await client.get_timeline(count=count)
100 |         return convert_tweets_to_markdown(tweets)
101 |     except Exception as e:
102 |         logger.error(f"Failed to get timeline: {e}")
103 |         return f"Failed to get timeline: {e}"
104 | 
105 | @mcp.tool()
106 | async def get_latest_timeline(count: int = 20) -> str:
107 |     """Get tweets from your home timeline (Following)."""
108 |     try:
109 |         client = await get_twitter_client()
110 |         tweets = await client.get_latest_timeline(count=count)
111 |         return convert_tweets_to_markdown(tweets)
112 |     except Exception as e:
113 |         logger.error(f"Failed to get latest timeline: {e}")
114 |         return f"Failed to get latest timeline: {e}"
115 | 
116 | # New write tools
117 | @mcp.tool()
118 | async def post_tweet(
119 |     text: str,
120 |     media_paths: Optional[List[str]] = None,
121 |     reply_to: Optional[str] = None,
122 |     tags: Optional[List[str]] = None
123 | ) -> str:
124 |     """Post a tweet with optional media, reply, and tags."""
125 |     try:
126 |         if not check_rate_limit('tweet'):
127 |             return "Rate limit exceeded for tweets. Please wait before posting again."
128 | 
129 |         client = await get_twitter_client()
130 | 
131 |         # Handle tags by converting to mentions
132 |         if tags:
133 |             mentions = ' '.join(f"@{tag.lstrip('@')}" for tag in tags)
134 |             text = f"""{text}
135 | {mentions}"""
136 | 
137 |         # Upload media if provided
138 |         media_ids = []
139 |         if media_paths:
140 |             for path in media_paths:
141 |                 media_id = await client.upload_media(path, wait_for_completion=True)
142 |                 media_ids.append(media_id)
143 | 
144 |         # Create the tweet
145 |         tweet = await client.create_tweet(
146 |             text=text,
147 |             media_ids=media_ids if media_ids else None,
148 |             reply_to=reply_to
149 |         )
150 |         RATE_LIMITS.setdefault('tweet', []).append(time.time())
151 |         return f"Successfully posted tweet: {tweet.id}"
152 |     except Exception as e:
153 |         logger.error(f"Failed to post tweet: {e}")
154 |         return f"Failed to post tweet: {e}"
155 | 
156 | @mcp.tool()
157 | async def delete_tweet(tweet_id: str) -> str:
158 |     """Delete a tweet by its ID."""
159 |     try:
160 |         client = await get_twitter_client()
161 |         await client.delete_tweet(tweet_id)
162 |         return f"Successfully deleted tweet {tweet_id}"
163 |     except Exception as e:
164 |         logger.error(f"Failed to delete tweet: {e}")
165 |         return f"Failed to delete tweet: {e}"
166 | 
167 | @mcp.tool()
168 | async def send_dm(user_id: str, message: str, media_path: Optional[str] = None) -> str:
169 |     """Send a direct message to a user."""
170 |     try:
171 |         if not check_rate_limit('dm'):
172 |             return "Rate limit exceeded for DMs. Please wait before sending again."
173 | 
174 |         client = await get_twitter_client()
175 | 
176 |         media_id = None
177 |         if media_path:
178 |             media_id = await client.upload_media(media_path, wait_for_completion=True)
179 | 
180 |         await client.send_dm(
181 |             user_id=user_id,
182 |             text=message,
183 |             media_id=media_id
184 |         )
185 |         RATE_LIMITS.setdefault('dm', []).append(time.time())
186 |         return f"Successfully sent DM to user {user_id}"
187 |     except Exception as e:
188 |         logger.error(f"Failed to send DM: {e}")
189 |         return f"Failed to send DM: {e}"
190 | 
191 | @mcp.tool()
192 | async def delete_dm(message_id: str) -> str:
193 |     """Delete a direct message by its ID."""
194 |     try:
195 |         client = await get_twitter_client()
196 |         await client.delete_dm(message_id)
197 |         return f"Successfully deleted DM {message_id}"
198 |     except Exception as e:
199 |         logger.error(f"Failed to delete DM: {e}")
200 |         return f"Failed to delete DM: {e}"
201 | 
202 | def convert_tweets_to_markdown(tweets) -> str:
203 |     """Convert a list of tweets to markdown format."""
204 |     result = []
205 |     for tweet in tweets:
206 |         result.append(f"### @{tweet.user.screen_name}")
207 |         result.append(f"**{tweet.created_at}**")
208 |         result.append(tweet.text)
209 |         if tweet.media:
210 |             for media in tweet.media:
211 |                 result.append(f"![media]({media.url})")
212 |         result.append("---")
213 |     return "\n".join(result)
214 | 
```