├── LICENSE ├── src └── mcpagentai │ ├── __main__.py │ ├── core │ ├── __init__.py │ ├── agent_base.py │ ├── multi_tool_agent.py │ └── logging.py │ ├── tools │ ├── __init__.py │ ├── eliza │ │ ├── __init__.py │ │ ├── scripts │ │ │ ├── monitor.sh │ │ │ ├── setup.sh │ │ │ └── run.sh │ │ ├── agent.py │ │ └── mcp_agent.py │ ├── twitter │ │ ├── __init__.py │ │ ├── handlers │ │ │ ├── __init__.py │ │ │ ├── dictionary_handler.py │ │ │ ├── time_handler.py │ │ │ ├── stock_handler.py │ │ │ ├── crypto_handler.py │ │ │ ├── currency_handler.py │ │ │ └── weather_handler.py │ │ ├── query_handler.py │ │ ├── agent_client_wrapper.py │ │ ├── api_agent.py │ │ └── agent.py │ ├── dictionary_agent.py │ ├── calculator_agent.py │ ├── stock_agent.py │ ├── crypto_agent.py │ ├── currency_agent.py │ ├── weather_agent.py │ └── time_agent.py │ ├── __init__.py │ ├── main.py │ ├── server.py │ └── defs.py ├── store └── replied_tweets.json ├── requirements.txt ├── .gitignore ├── .env ├── pyproject.toml ├── cookies.json ├── Dockerfile ├── scritps └── run_agent.sh ├── README.md └── uv.lock /LICENSE: -------------------------------------------------------------------------------- 1 | MIT -------------------------------------------------------------------------------- /src/mcpagentai/__main__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /store/replied_tweets.json: -------------------------------------------------------------------------------- 1 | [] -------------------------------------------------------------------------------- /src/mcpagentai/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/mcpagentai/tools/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/mcpagentai/tools/eliza/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/mcpagentai/tools/twitter/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/mcpagentai/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.1.0" 2 | -------------------------------------------------------------------------------- /src/mcpagentai/tools/twitter/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | mcp 2 | pydantic 3 | requests 4 | tweepy 5 | anthropic -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | package-lock.json 2 | package.json 3 | cookies.json 4 | node_modules/ 5 | __pycache__/ 6 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | ANTHROPIC_API_KEY= 2 | ELIZA_PATH=/your/local/or/docker/path/eliza 3 | TWITTER_USERNAME= 4 | TWITTER_EMAIL= 5 | TWITTER_PASSWORD= 6 | PERSONALITY_CONFIG=/your/local/or/docker/path/johncarter.character.json 7 | RUN_AGENT=True -------------------------------------------------------------------------------- /src/mcpagentai/main.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | 4 | from mcpagentai.server import start_server 5 | 6 | def main(): 7 | """ 8 | CLI entry point for the 'mcpagentai' command. 9 | """ 10 | local_timezone = os.getenv("LOCAL_TIMEZONE", None) 11 | asyncio.run(start_server(local_timezone=local_timezone)) 12 | -------------------------------------------------------------------------------- /src/mcpagentai/tools/eliza/scripts/monitor.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "Monitoring server and client processes and ports (e.g. 3000, 5173)" 4 | while true; do 5 | echo "==== Server and Client Ports ====" 6 | lsof -i :3000 -i :5173 | grep LISTEN || echo "No active processes" 7 | echo "==== Node Processes ====" 8 | ps aux | grep -E "(node.*3000|node.*5173)" | grep -v grep 9 | sleep 2 10 | done 11 | -------------------------------------------------------------------------------- /src/mcpagentai/tools/eliza/scripts/setup.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euxo pipefail 3 | 4 | # 1) Check environment 5 | if [ -z "${ELIZA_PATH:-}" ]; then 6 | echo "ERROR: ELIZA_PATH environment variable is not set." 7 | echo "Please set ELIZA_PATH to the path containing the ElizaOS code." 8 | exit 1 9 | fi 10 | 11 | # 2) Go to the Eliza path 12 | cd "$ELIZA_PATH" 13 | 14 | # 3) Install and build 15 | echo "Installing Node dependencies using pnpm..." 16 | pnpm install 17 | 18 | echo "Building ElizaOS..." 19 | pnpm build 20 | 21 | echo "======================================================================" 22 | echo "ElizaOS Setup complete." 23 | echo "Next steps:" 24 | echo " - To start the server: pnpm start --characters=\"characters/johncarter.character.json\"" 25 | echo " - To start the client: pnpm start:client" 26 | echo "======================================================================" 27 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "mcpagentai" 3 | version = "1.0.0" 4 | description = "A generic multi-tool MCP Agent with Twitter, Eliza, Weather, Dictionary, Calculator, etc." 5 | readme = "README.md" 6 | requires-python = ">=3.12" 7 | authors = [ 8 | { name = "MCPAgentAIDev", email = "dev@mcpagentai.com" }, 9 | ] 10 | keywords = ["mcp", "agent", "LLM", "time", "weather", "dictionary", 11 | "calculator", "currency", "twitter", "aiagent", "mcp"] 12 | license = { text = "MIT" } 13 | classifiers = [ 14 | "Development Status :: 3 - Alpha", 15 | "Intended Audience :: Developers", 16 | "License :: OSI Approved :: MIT License", 17 | "Programming Language :: Python :: 3", 18 | "Programming Language :: Python :: 3.12", 19 | ] 20 | dependencies = [ 21 | "mcp", 22 | "pydantic", 23 | "requests", 24 | "tweepy", 25 | "anthropic", 26 | ] 27 | 28 | [project.scripts] 29 | mcpagentai = "mcpagentai.main:main" 30 | 31 | [build-system] 32 | requires = ["hatchling"] 33 | build-backend = "hatchling.build" 34 | 35 | [tool.uv] 36 | dev-dependencies = [ 37 | "pytest>=7.0", 38 | "ruff>=0.8.1", 39 | ] 40 | -------------------------------------------------------------------------------- /src/mcpagentai/tools/twitter/query_handler.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Dict, Any, Optional 3 | 4 | class QueryHandler(ABC): 5 | """Base interface for all query handlers that can be plugged into the Twitter agent""" 6 | 7 | @property 8 | @abstractmethod 9 | def query_type(self) -> str: 10 | """Return the type of queries this handler can process (e.g., 'weather', 'stock')""" 11 | pass 12 | 13 | @property 14 | @abstractmethod 15 | def available_params(self) -> Dict[str, str]: 16 | """Return a dictionary of available parameters and their descriptions""" 17 | pass 18 | 19 | @abstractmethod 20 | def handle_query(self, params: Dict[str, Any]) -> Optional[str]: 21 | """ 22 | Handle a query with the given parameters 23 | Returns a formatted string response or None if query cannot be handled 24 | """ 25 | pass 26 | 27 | @property 28 | @abstractmethod 29 | def examples(self) -> Dict[str, str]: 30 | """Return example queries and their expected parameter outputs""" 31 | pass -------------------------------------------------------------------------------- /cookies.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "key": "auth_token", 4 | "value": "REALVALUE", 5 | "domain": "twitter.com", 6 | "path": "/", 7 | "secure": true, 8 | "httpOnly": true, 9 | "hostOnly": false, 10 | "creation": "2025-01-15T00:10:43.814Z", 11 | "lastAccessed": "2025-01-15T00:10:44.282Z" 12 | }, 13 | { 14 | "key": "ct0", 15 | "value": "REALVALUE", 16 | "domain": "twitter.com", 17 | "path": "/", 18 | "secure": true, 19 | "hostOnly": false, 20 | "creation": "2025-01-15T00:10:43.815Z", 21 | "lastAccessed": "2025-01-15T00:10:44.282Z" 22 | }, 23 | { 24 | "key": "twid", 25 | "value": "REALVALUE", 26 | "domain": "twitter.com", 27 | "path": "/", 28 | "secure": true, 29 | "httpOnly": true, 30 | "hostOnly": false, 31 | "creation": "2025-01-15T00:10:43.816Z", 32 | "lastAccessed": "2025-01-15T00:10:44.282Z" 33 | }, 34 | { 35 | "key": "guest_id", 36 | "value": "REALVALUE", 37 | "domain": "twitter.com", 38 | "path": "/", 39 | "secure": true, 40 | "hostOnly": false, 41 | "creation": "2025-01-15T00:10:43.816Z", 42 | "lastAccessed": "2025-01-15T00:10:44.282Z" 43 | } 44 | ] -------------------------------------------------------------------------------- /src/mcpagentai/core/agent_base.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import Sequence, Union 3 | from dotenv import load_dotenv 4 | from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource 5 | 6 | from .logging import get_logger 7 | 8 | class MCPAgent(abc.ABC): 9 | """ 10 | Master abstract base class for MCP Agents of any type. 11 | """ 12 | 13 | def __init__(self): 14 | load_dotenv() 15 | self.logger = get_logger(self.__class__.__name__) 16 | self.logger.debug(f"Initializing agent: {self.__class__.__name__}") 17 | 18 | @abc.abstractmethod 19 | def list_tools(self) -> list[Tool]: 20 | """ 21 | Return a list of the tools provided by this agent. 22 | """ 23 | pass 24 | 25 | @abc.abstractmethod 26 | def call_tool( 27 | self, 28 | name: str, 29 | arguments: dict 30 | ) -> Sequence[Union[TextContent, ImageContent, EmbeddedResource]]: 31 | """ 32 | Call the specified tool by name with the given arguments, 33 | and return a sequence of textual/image/embedded resources (MCP content). 34 | """ 35 | pass 36 | 37 | def has_tool(self, tool_name: str) -> bool: 38 | """ 39 | Determine if this agent implements a tool by the given name. 40 | """ 41 | return any(tool.name == tool_name for tool in self.list_tools()) 42 | -------------------------------------------------------------------------------- /src/mcpagentai/core/multi_tool_agent.py: -------------------------------------------------------------------------------- 1 | from typing import Sequence, Union 2 | from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource 3 | 4 | from .agent_base import MCPAgent 5 | 6 | 7 | class MultiToolAgent(MCPAgent): 8 | """ 9 | A composite agent that combines multiple MCPAgent subclasses 10 | under a single interface. 11 | """ 12 | 13 | def __init__(self, agents: list[MCPAgent]): 14 | super().__init__() 15 | self._agents = agents 16 | self.logger.info(f"Initialized MultiToolAgent with {len(self._agents)} sub-agents.") 17 | 18 | def list_tools(self) -> list[Tool]: 19 | """ 20 | Return the union of all tools from each sub-agent. 21 | """ 22 | combined_tools = [] 23 | for agent in self._agents: 24 | combined_tools.extend(agent.list_tools()) 25 | return combined_tools 26 | 27 | def has_tool(self, tool_name: str) -> bool: 28 | """ 29 | Determine if this agent implements a tool by the given name. 30 | """ 31 | return any(tool.name == tool_name for tool in self.list_tools()) 32 | 33 | def call_tool( 34 | self, 35 | name: str, 36 | arguments: dict 37 | ) -> Sequence[Union[TextContent, ImageContent, EmbeddedResource]]: 38 | """ 39 | Route the tool call to whichever agent implements it. 40 | """ 41 | for agent in self._agents: 42 | if agent.has_tool(name): 43 | return agent.call_tool(name, arguments) 44 | raise ValueError(f"Unknown tool: {name}") 45 | -------------------------------------------------------------------------------- /src/mcpagentai/core/logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | # ANSI escape codes for colors 4 | RESET = "\033[0m" 5 | COLOR_CODES = { 6 | logging.DEBUG: "\033[0;36m", # Cyan 7 | logging.INFO: "\033[0;32m", # Green 8 | logging.WARNING: "\033[0;33m", # Yellow 9 | logging.ERROR: "\033[0;31m", # Red 10 | logging.CRITICAL: "\033[1;31m", # Bold Red 11 | } 12 | 13 | 14 | class ColoredFormatter(logging.Formatter): 15 | """ 16 | Custom logging formatter to add colors based on the log level. 17 | """ 18 | 19 | def format(self, record: logging.LogRecord) -> str: 20 | log_color = COLOR_CODES.get(record.levelno, RESET) 21 | formatted_message = super().format(record) 22 | return f"{log_color}{formatted_message}{RESET}" 23 | 24 | 25 | def get_logger(name: str) -> logging.Logger: 26 | """ 27 | Return a named logger with colored output. 28 | 29 | Args: 30 | name (str): The name of the logger. 31 | 32 | Returns: 33 | logging.Logger: Configured logger instance. 34 | """ 35 | logger = logging.getLogger(name) 36 | 37 | if not logger.handlers: 38 | # Create console handler 39 | handler = logging.StreamHandler() 40 | 41 | # Define log format with color support 42 | formatter = ColoredFormatter( 43 | fmt="%(asctime)s [%(levelname)s] [%(name)s]: %(message)s", 44 | datefmt="%Y-%m-%d %H:%M:%S", 45 | ) 46 | 47 | handler.setFormatter(formatter) 48 | logger.addHandler(handler) 49 | 50 | # Set the default logging level (can be adjusted as needed) 51 | logger.setLevel(logging.DEBUG) 52 | 53 | return logger 54 | -------------------------------------------------------------------------------- /src/mcpagentai/tools/twitter/handlers/dictionary_handler.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Dict, Any, Optional 3 | 4 | from mcpagentai.tools.twitter.query_handler import QueryHandler 5 | from mcpagentai.tools.dictionary_agent import DictionaryAgent 6 | 7 | class DictionaryQueryHandler(QueryHandler): 8 | def __init__(self): 9 | self.dictionary_agent = DictionaryAgent() 10 | 11 | @property 12 | def query_type(self) -> str: 13 | return "dictionary" 14 | 15 | @property 16 | def available_params(self) -> Dict[str, str]: 17 | return { 18 | "word": "The word to look up the definition for" 19 | } 20 | 21 | def handle_query(self, params: Dict[str, Any]) -> Optional[str]: 22 | try: 23 | # Get word from params 24 | word = params.get("word", "").strip().lower() 25 | if not word: 26 | return None 27 | 28 | # Get definition 29 | definition_data = self.dictionary_agent.call_tool("define_word", {"word": word}) 30 | if definition_data and definition_data[0].text: 31 | result = json.loads(definition_data[0].text) 32 | if "definition" in result: 33 | return f"{result['word']}: {result['definition']}" 34 | 35 | return None 36 | 37 | except Exception as e: 38 | print(f"Error in dictionary handler: {e}") 39 | return None 40 | 41 | @property 42 | def examples(self) -> Dict[str, str]: 43 | return { 44 | "Define algorithm": {"word": "algorithm"}, 45 | "What does serendipity mean?": {"word": "serendipity"}, 46 | "Look up the word ephemeral": {"word": "ephemeral"}, 47 | "Definition of paradigm": {"word": "paradigm"} 48 | } -------------------------------------------------------------------------------- /src/mcpagentai/tools/dictionary_agent.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Sequence, Union 3 | 4 | from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource 5 | 6 | from mcpagentai.core.agent_base import MCPAgent 7 | 8 | 9 | class DictionaryAgent(MCPAgent): 10 | """ 11 | Agent that looks up word definitions (stubbed example). 12 | """ 13 | 14 | def list_tools(self) -> list[Tool]: 15 | return [ 16 | Tool( 17 | name="define_word", 18 | description="Look up the definition of a word", 19 | inputSchema={ 20 | "type": "object", 21 | "properties": { 22 | "word": { 23 | "type": "string", 24 | "description": "The word to define", 25 | }, 26 | }, 27 | "required": ["word"], 28 | }, 29 | ) 30 | ] 31 | 32 | def call_tool( 33 | self, 34 | name: str, 35 | arguments: dict 36 | ) -> Sequence[Union[TextContent, ImageContent, EmbeddedResource]]: 37 | if name == "define_word": 38 | return self._handle_define_word(arguments) 39 | else: 40 | raise ValueError(f"Unknown tool: {name}") 41 | 42 | def _handle_define_word(self, arguments: dict) -> Sequence[TextContent]: 43 | word = arguments["word"] 44 | # Stubbed. In reality, you'd call an external dictionary API, e.g. Merriam-Webster. 45 | mock_definition = ( 46 | f"{word.capitalize()}: [Mock definition for demonstration purposes]" 47 | ) 48 | return [ 49 | TextContent( 50 | type="text", 51 | text=json.dumps({"word": word, "definition": mock_definition}, indent=2) 52 | ) 53 | ] 54 | -------------------------------------------------------------------------------- /src/mcpagentai/tools/calculator_agent.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Sequence, Union 3 | 4 | from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource 5 | 6 | from mcpagentai.core.agent_base import MCPAgent 7 | 8 | 9 | class CalculatorAgent(MCPAgent): 10 | """ 11 | Agent that performs arbitrary math expressions using Python's eval (cautiously). 12 | For real usage, consider a safe parser or sandbox for security. 13 | """ 14 | 15 | def list_tools(self) -> list[Tool]: 16 | return [ 17 | Tool( 18 | name="calculate_expression", 19 | description="Calculate a math expression (dangerous: uses eval).", 20 | inputSchema={ 21 | "type": "object", 22 | "properties": { 23 | "expression": { 24 | "type": "string", 25 | "description": "A mathematical expression in Python syntax", 26 | }, 27 | }, 28 | "required": ["expression"], 29 | }, 30 | ) 31 | ] 32 | 33 | def call_tool( 34 | self, 35 | name: str, 36 | arguments: dict 37 | ) -> Sequence[Union[TextContent, ImageContent, EmbeddedResource]]: 38 | if name == "calculate_expression": 39 | return self._handle_calculate_expression(arguments) 40 | else: 41 | raise ValueError(f"Unknown tool: {name}") 42 | 43 | def _handle_calculate_expression(self, arguments: dict) -> Sequence[TextContent]: 44 | expr = arguments["expression"] 45 | try: 46 | result = eval(expr) # For demonstration only, not recommended for production 47 | except Exception as e: 48 | result = f"Error evaluating expression: {e}" 49 | return [ 50 | TextContent( 51 | type="text", 52 | text=json.dumps({"expression": expr, "result": result}, indent=2) 53 | ) 54 | ] 55 | -------------------------------------------------------------------------------- /src/mcpagentai/tools/twitter/handlers/time_handler.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Any, Optional 2 | 3 | from mcpagentai.tools.twitter.query_handler import QueryHandler 4 | from mcpagentai.tools.time_agent import TimeAgent 5 | 6 | class TimeQueryHandler(QueryHandler): 7 | def __init__(self): 8 | self.time_agent = TimeAgent() 9 | 10 | # Common timezone aliases 11 | self.timezone_aliases = { 12 | "ny": "America/New_York", 13 | "nyc": "America/New_York", 14 | "est": "America/New_York", 15 | "la": "America/Los_Angeles", 16 | "sf": "America/Los_Angeles", 17 | "pst": "America/Los_Angeles", 18 | "london": "Europe/London", 19 | "uk": "Europe/London", 20 | "tokyo": "Asia/Tokyo", 21 | "paris": "Europe/Paris", 22 | "berlin": "Europe/Berlin" 23 | } 24 | 25 | @property 26 | def query_type(self) -> str: 27 | return "time" 28 | 29 | @property 30 | def available_params(self) -> Dict[str, str]: 31 | return { 32 | "timezone": "Timezone name (e.g., America/New_York)", 33 | "city": "Common city name (e.g., 'nyc', 'sf', 'london')" 34 | } 35 | 36 | def handle_query(self, params: Dict[str, Any]) -> Optional[str]: 37 | try: 38 | # Get timezone from params 39 | timezone = params.get("timezone") 40 | city = params.get("city", "").lower() 41 | 42 | # If city is provided and in our list, use its timezone 43 | if city and city in self.timezone_aliases: 44 | timezone = self.timezone_aliases[city] 45 | 46 | # Default to NY if no timezone provided 47 | timezone = timezone or "America/New_York" 48 | 49 | # Get time data 50 | time_data = self.time_agent.call_tool("get_current_time", {"timezone": timezone}) 51 | if time_data and time_data[0].text: 52 | return time_data[0].text 53 | 54 | return None 55 | 56 | except Exception as e: 57 | print(f"Error in time handler: {e}") 58 | return None 59 | 60 | @property 61 | def examples(self) -> Dict[str, str]: 62 | return { 63 | "What time is it in NYC?": {"city": "nyc"}, 64 | "Time in London?": {"city": "london"}, 65 | "What's the time in Tokyo?": {"city": "tokyo"}, 66 | "Current time in America/Los_Angeles?": {"timezone": "America/Los_Angeles"} 67 | } -------------------------------------------------------------------------------- /src/mcpagentai/server.py: -------------------------------------------------------------------------------- 1 | from typing import Sequence 2 | 3 | from mcp.server import Server 4 | from mcp.server.stdio import stdio_server 5 | from mcp.types import TextContent, ImageContent, EmbeddedResource 6 | 7 | from mcpagentai.core.logging import get_logger 8 | from mcpagentai.core.multi_tool_agent import MultiToolAgent 9 | 10 | # Sub-agents 11 | from mcpagentai.tools.calculator_agent import CalculatorAgent 12 | from mcpagentai.tools.currency_agent import CurrencyAgent 13 | from mcpagentai.tools.dictionary_agent import DictionaryAgent 14 | from mcpagentai.tools.eliza.agent import ElizaAgent 15 | from mcpagentai.tools.eliza.mcp_agent import ElizaMCPAgent 16 | from mcpagentai.tools.stock_agent import StockAgent 17 | from mcpagentai.tools.time_agent import TimeAgent 18 | #from mcpagentai.tools.twitter.api_agent import TwitterAgent 19 | # from mcpagentai.tools.twitter.client_agent import TwitterAgent 20 | from mcpagentai.tools.twitter.agent import TwitterAgent 21 | from mcpagentai.tools.weather_agent import WeatherAgent 22 | 23 | async def start_server(local_timezone: str | None = None) -> None: 24 | logger = get_logger("mcpagentai.server") 25 | logger.info("Starting MCPAgentAI server...") 26 | 27 | time_agent = TimeAgent(local_timezone=local_timezone) 28 | weather_agent = WeatherAgent() 29 | dictionary_agent = DictionaryAgent() 30 | calculator_agent = CalculatorAgent() 31 | currency_agent = CurrencyAgent() 32 | eliza_agent = ElizaAgent() 33 | eliza_mcp_agent = ElizaMCPAgent() 34 | stock_agent = StockAgent() 35 | twitter_agent = TwitterAgent() 36 | 37 | # Combine them into one aggregator 38 | multi_tool_agent = MultiToolAgent([ 39 | # time_agent, 40 | # weather_agent, 41 | # dictionary_agent, 42 | # calculator_agent, 43 | # currency_agent, 44 | # eliza_agent, 45 | # eliza_mcp_agent, 46 | # stock_agent, 47 | twitter_agent, 48 | ]) 49 | 50 | server = Server("mcpagentai") 51 | 52 | @server.list_tools() 53 | async def list_tools(): 54 | """ 55 | List all available tools. 56 | """ 57 | logger.debug("server.list_tools called") 58 | return multi_tool_agent.list_tools() 59 | 60 | @server.call_tool() 61 | async def call_tool(name: str, arguments: dict) -> Sequence[TextContent | ImageContent | EmbeddedResource]: 62 | """ 63 | Dispatch calls to the aggregator agent, which routes to the correct sub-agent. 64 | """ 65 | try: 66 | return multi_tool_agent.call_tool(name, arguments) 67 | except Exception as e: 68 | logger.exception("Error in call_tool") 69 | # Avoid using e.message. Use str(e) instead 70 | raise ValueError(f"Error processing request: {str(e)}") from e 71 | 72 | options = server.create_initialization_options() 73 | 74 | async with stdio_server() as (read_stream, write_stream): 75 | logger.info("Running server on stdio_server...") 76 | await server.run(read_stream, write_stream, options) 77 | -------------------------------------------------------------------------------- /src/mcpagentai/tools/twitter/handlers/stock_handler.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from typing import Dict, Any, Optional 4 | 5 | from mcpagentai.tools.twitter.query_handler import QueryHandler 6 | from mcpagentai.defs import StockTools 7 | from mcpagentai.tools.stock_agent import StockAgent 8 | 9 | class StockQueryHandler(QueryHandler): 10 | def __init__(self): 11 | self.stock_agent = StockAgent() 12 | 13 | # Common stock tickers and their names 14 | self.tickers = { 15 | "apple": "AAPL", 16 | "google": "GOOGL", 17 | "microsoft": "MSFT", 18 | "nvidia": "NVDA", 19 | "amd": "AMD", 20 | "tesla": "TSLA", 21 | "amazon": "AMZN", 22 | "meta": "META", 23 | "spy": "SPY", # S&P 500 ETF 24 | "qqq": "QQQ", # NASDAQ ETF 25 | "dia": "DIA", # Dow Jones ETF 26 | "iwm": "IWM" # Russell 2000 ETF 27 | } 28 | 29 | @property 30 | def query_type(self) -> str: 31 | return "stock" 32 | 33 | @property 34 | def available_params(self) -> Dict[str, str]: 35 | return { 36 | "ticker": "Stock ticker symbol (e.g., AAPL, GOOGL)", 37 | "company": "Company name (e.g., Apple, Google)" 38 | } 39 | 40 | def handle_query(self, params: Dict[str, Any]) -> Optional[str]: 41 | try: 42 | if not os.getenv("ALPHA_VANTAGE_API_KEY"): 43 | return "API Limit Reached - Stock Data Unavailable" 44 | 45 | # Get ticker from params 46 | ticker = params.get("ticker", "").upper() 47 | company = params.get("company", "").lower() 48 | 49 | # If company name provided, try to get ticker 50 | if company and company in self.tickers: 51 | ticker = self.tickers[company] 52 | 53 | # Default to AAPL if no ticker provided 54 | ticker = ticker or "AAPL" 55 | 56 | # Get stock data 57 | try: 58 | stock_data = self.stock_agent.call_tool(StockTools.GET_STOCK_PRICE_TODAY.value, {"ticker": ticker}) 59 | if stock_data and stock_data[0].text: 60 | stock_json = json.loads(stock_data[0].text) 61 | if "price" in stock_json: 62 | formatted_price = "{:.2f}".format(float(stock_json["price"])) 63 | return f"{ticker}: ${formatted_price}" 64 | except ValueError as e: 65 | print(f"Alpha Vantage API error: {e}") 66 | return "API Limit Reached - Stock Data Unavailable" 67 | 68 | return "API Limit Reached - Stock Data Unavailable" 69 | 70 | except Exception as e: 71 | print(f"Error in stock handler: {e}") 72 | return "API Limit Reached - Stock Data Unavailable" 73 | 74 | @property 75 | def examples(self) -> Dict[str, str]: 76 | return { 77 | "What's the Apple stock price?": {"company": "apple"}, 78 | "GOOGL price?": {"ticker": "GOOGL"}, 79 | "How's NVIDIA doing?": {"company": "nvidia"}, 80 | "What's the S&P 500 at?": {"ticker": "SPY"}, 81 | "Price of TSLA?": {"ticker": "TSLA"} 82 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # ---------------------------------------------- 2 | # 1st Stage: Build Python dependencies using uv 3 | # ---------------------------------------------- 4 | FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS uv 5 | 6 | WORKDIR /app 7 | 8 | # Set environment variables for uv 9 | ENV UV_COMPILE_BYTECODE=1 10 | ENV UV_LINK_MODE=copy 11 | ENV PYTHONPATH=/app/src 12 | 13 | # Copy Python dependency files and install dependencies 14 | RUN --mount=type=cache,target=/root/.cache/uv \ 15 | --mount=type=bind,source=uv.lock,target=uv.lock \ 16 | --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ 17 | uv sync --frozen --no-install-project --no-dev --no-editable 18 | 19 | # Copy the rest of the application code 20 | ADD . /app 21 | 22 | # Run uv sync again after copying the full code 23 | RUN --mount=type=cache,target=/root/.cache/uv \ 24 | uv sync --frozen --no-dev --no-editable 25 | 26 | 27 | # ------------------------------------------------ 28 | # (OPTIONAL) 2nd Stage: Install Node.js dependencies 29 | # ------------------------------------------------ 30 | FROM node:18-slim AS node_builder 31 | # 32 | WORKDIR /app 33 | # 34 | ## Create a minimal package.json for installing agent-twitter-client 35 | RUN printf '{\n\ 36 | "name": "agent-twitter-client-setup",\n\ 37 | "version": "1.0.0",\n\ 38 | "dependencies": {\n\ 39 | "agent-twitter-client": "^0.0.18",\n\ 40 | "tough-cookie": "^4.0.0"\n\ 41 | }\n\ 42 | }\n' > package.json 43 | # 44 | ## Install the required Node.js packages 45 | RUN npm install 46 | 47 | 48 | # -------------------------------------------- 49 | # 3rd Stage: Final runtime image 50 | # -------------------------------------------- 51 | FROM python:3.12-slim-bookworm 52 | 53 | WORKDIR /app 54 | 55 | # Copy Python virtual environment from the uv stage 56 | COPY --from=uv /app/.venv /app/.venv 57 | 58 | # OPTIONAL Copy Node.js dependencies from the node_builder stage 59 | COPY --from=node_builder /app/node_modules /app/node_modules 60 | COPY --from=node_builder /app/package.json /app/package.json 61 | 62 | # OPTIONAL Install Node.js in the runtime container 63 | RUN apt-get update && \ 64 | apt-get install -y curl && \ 65 | curl -fsSL https://deb.nodesource.com/setup_18.x | bash - && \ 66 | apt-get install -y nodejs && \ 67 | apt-get clean && \ 68 | rm -rf /var/lib/apt/lists/* 69 | 70 | # Set up environment variables for Agents and other 71 | ENV PATH="/app/.venv/bin:$PATH" 72 | ENV LOCAL_TIMEZONE=Europe/Warsaw 73 | ENV LOG_LEVEL=DEBUG 74 | 75 | # ElizaOS dependencies 76 | ENV ELIZA_PATH=/app/eliza 77 | ENV ELIZA_API_URL=http://192.168.1.14:5173/ 78 | 79 | # Twtitter dependencies 80 | ENV TWITTER_USERNAME= 81 | ENV TWITTER_PASSWORD= 82 | ENV TWITTER_EMAIL= 83 | 84 | ENV PERSONALITY_CONFIG=/app/eliza/charachter.json 85 | ENV ANTHROPIC_API_KEY= 86 | 87 | ENV TWITTER_API_KEY= 88 | ENV TWITTER_API_SECRET= 89 | ENV TWITTER_ACCESS_TOKEN= 90 | ENV TWITTER_ACCESS_SECRET= 91 | ENV TWITTER_CLIENT_ID= 92 | ENV TWITTER_CLIENT_SECRET= 93 | ENV TWITTER_BEARER_TOKEN= 94 | ENV RUN_AGENT=True 95 | 96 | 97 | # Verify installations 98 | RUN python --version # && node -v && npm -v 99 | 100 | # Ensure the mcpagentai script exists 101 | RUN if ! [ -x "$(command -v mcpagentai)" ]; then \ 102 | echo "mcpagentai not found in PATH"; \ 103 | exit 1; \ 104 | fi 105 | 106 | 107 | # Set the default entry point 108 | ENTRYPOINT ["mcpagentai"] 109 | -------------------------------------------------------------------------------- /scritps/run_agent.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | # ------------------------------------------------------------------------------ 5 | # This script is located at: 6 | # scripts/run_agent.sh 7 | # ------------------------------------------------------------------------------ 8 | 9 | # 1) Get the absolute path of THIS script 10 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 11 | 12 | # 2) Move to the project root (which is one level up from `scripts`): 13 | # scripts -> (project root) 14 | PROJECT_ROOT="$(dirname "${SCRIPT_DIR}")" 15 | cd "${PROJECT_ROOT}" || exit 1 16 | 17 | # Now you are at the project root and can run your desired commands here. 18 | echo "Current directory: $(pwd)" 19 | 20 | 21 | # ------------------------------------------------------------------------------ 22 | # Color definitions for friendly output 23 | # ------------------------------------------------------------------------------ 24 | GREEN='\033[0;32m' 25 | YELLOW='\033[1;33m' 26 | RED='\033[0;31m' 27 | NC='\033[0m' 28 | 29 | echo -e "${GREEN}🚀 Starting Twitter AI Agent...${NC}\n" 30 | 31 | # ------------------------------------------------------------------------------ 32 | # Check for Node.js installation 33 | # ------------------------------------------------------------------------------ 34 | if ! command -v node > /dev/null 2>&1; then 35 | echo -e "${RED}❌ Node.js is not installed. Please install Node.js first.${NC}" 36 | echo -e "Visit: https://nodejs.org/en/download/" 37 | exit 1 38 | fi 39 | 40 | # ------------------------------------------------------------------------------ 41 | # Check for npm installation 42 | # ------------------------------------------------------------------------------ 43 | if ! command -v npm > /dev/null 2>&1; then 44 | echo -e "${RED}❌ npm is not installed. Please install npm first.${NC}" 45 | exit 1 46 | fi 47 | 48 | # ------------------------------------------------------------------------------ 49 | # Check for .env file in the project root 50 | # ------------------------------------------------------------------------------ 51 | if [ ! -f ".env" ]; then 52 | echo -e "${YELLOW}⚠️ No .env file found in project root. Please create .env with your credentials first.${NC}" 53 | exit 1 54 | fi 55 | 56 | # ------------------------------------------------------------------------------ 57 | # Install or update Node.js dependencies 58 | # ------------------------------------------------------------------------------ 59 | echo -e "${GREEN}📦 Checking Node.js dependencies...${NC}" 60 | 61 | # Create package.json if it doesn't exist 62 | if [ ! -f "package.json" ]; then 63 | echo -e "${YELLOW}Creating package.json...${NC}" 64 | cat < package.json 65 | { 66 | "name": "mcpagentai-twitter", 67 | "version": "1.0.0", 68 | "private": true, 69 | "dependencies": { 70 | "agent-twitter-client": "^0.0.18", 71 | "tough-cookie": "^4.0.0" 72 | } 73 | } 74 | EOF 75 | fi 76 | 77 | # Install Node.js dependencies if needed 78 | if ! npm list agent-twitter-client &>/dev/null || ! npm list tough-cookie &>/dev/null; then 79 | echo -e "${YELLOW}Installing Node.js dependencies...${NC}" 80 | npm install --no-audit --no-fund || { 81 | echo -e "${RED}❌ Failed to install Node.js dependencies${NC}" 82 | exit 1 83 | } 84 | fi 85 | 86 | # ------------------------------------------------------------------------------ 87 | # Create store directory and necessary files 88 | # ------------------------------------------------------------------------------ 89 | echo -e "${GREEN}📁 Setting up storage...${NC}" 90 | mkdir -p store 91 | touch store/replied_tweets.json 92 | touch cookies.json 93 | 94 | # ------------------------------------------------------------------------------ 95 | # Set up Python environment 96 | # ------------------------------------------------------------------------------ 97 | export PYTHONPATH=../../../../../src 98 | export LOG_LEVEL=DEBUG 99 | 100 | echo -e "${GREEN}✨ Starting agent with configured personality...${NC}\n" 101 | 102 | # ------------------------------------------------------------------------------ 103 | # Finally, start the agent 104 | # ------------------------------------------------------------------------------ 105 | mcpagentai 106 | -------------------------------------------------------------------------------- /src/mcpagentai/tools/twitter/handlers/crypto_handler.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Dict, Any, Optional 3 | 4 | from mcpagentai.tools.twitter.query_handler import QueryHandler 5 | from mcpagentai.tools.crypto_agent import CryptoAgent 6 | from mcpagentai.defs import CryptoTools 7 | from mcpagentai.core.logging import get_logger 8 | 9 | class CryptoQueryHandler(QueryHandler): 10 | def __init__(self): 11 | self.crypto_agent = CryptoAgent() 12 | self.logger = get_logger("mcpagentai.crypto_handler") 13 | 14 | # Common crypto aliases 15 | self.crypto_aliases = { 16 | "bitcoin": "BTC", 17 | "btc": "BTC", 18 | "ethereum": "ETH", 19 | "eth": "ETH", 20 | "dogecoin": "DOGE", 21 | "doge": "DOGE", 22 | "cardano": "ADA", 23 | "ada": "ADA", 24 | "solana": "SOL", 25 | "sol": "SOL", 26 | "ripple": "XRP", 27 | "xrp": "XRP", 28 | "polkadot": "DOT", 29 | "dot": "DOT" 30 | } 31 | 32 | @property 33 | def query_type(self) -> str: 34 | return "crypto" 35 | 36 | @property 37 | def available_params(self) -> Dict[str, str]: 38 | return { 39 | "symbol": "Cryptocurrency symbol or common name (e.g., 'BTC', 'ethereum')", 40 | "quote_currency": "Quote currency (e.g., 'USD', 'USDT'). Defaults to USDT.", 41 | "detailed": "Whether to fetch detailed ticker info (optional, defaults to False)" 42 | } 43 | 44 | def handle_query(self, params: Dict[str, Any]) -> Optional[str]: 45 | try: 46 | # Accept either 'symbol' or 'coin' parameter 47 | symbol = params.get("symbol") or params.get("coin") 48 | if not symbol: 49 | self.logger.warning("No symbol/coin provided in params") 50 | return None 51 | 52 | # Convert to lowercase for alias lookup 53 | symbol = symbol.lower() 54 | 55 | # Convert alias to symbol 56 | symbol = self.crypto_aliases.get(symbol, symbol.upper()) 57 | self.logger.info(f"Looking up price for {symbol}") 58 | 59 | # Get crypto data 60 | crypto_data = self.crypto_agent.call_tool( 61 | CryptoTools.GET_CRYPTO_PRICE.value, 62 | {"symbol": symbol} 63 | ) 64 | 65 | if crypto_data and crypto_data[0].text: 66 | result = json.loads(crypto_data[0].text) 67 | self.logger.debug(f"Got crypto data: {result}") 68 | 69 | if "price_usd" in result: 70 | price = float(result["price_usd"]) 71 | formatted_price = "${:,.2f}".format(price) 72 | change = result.get("change_24h", 0) 73 | change_emoji = "📈" if change and change > 0 else "📉" if change and change < 0 else "➡️" 74 | 75 | response = [ 76 | f"{symbol} Price: {formatted_price} USD {change_emoji}" 77 | ] 78 | 79 | if change: 80 | formatted_change = "{:+.2f}%".format(change) 81 | response.append(f"24h Change: {formatted_change}") 82 | 83 | response_text = "\n".join(response) 84 | self.logger.info(f"Generated response: {response_text}") 85 | return response_text 86 | else: 87 | self.logger.warning(f"No price data in response for {symbol}") 88 | else: 89 | self.logger.warning(f"No response from crypto agent for {symbol}") 90 | 91 | return None 92 | 93 | except Exception as e: 94 | self.logger.error(f"Error in crypto handler: {e}") 95 | return None 96 | 97 | @property 98 | def examples(self) -> Dict[str, str]: 99 | return { 100 | "What's the Bitcoin price?": {"symbol": "btc", "quote_currency": "USDT"}, 101 | "Show me ETH details": {"symbol": "eth", "quote_currency": "USDT", "detailed": True}, 102 | "Price of Dogecoin in USD": {"symbol": "doge", "quote_currency": "USD"}, 103 | "How much is Solana?": {"symbol": "sol", "quote_currency": "USDT"} 104 | } -------------------------------------------------------------------------------- /src/mcpagentai/tools/twitter/handlers/currency_handler.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Dict, Any, Optional 3 | 4 | from mcpagentai.tools.twitter.query_handler import QueryHandler 5 | from mcpagentai.defs import CurrencyTools 6 | from mcpagentai.tools.currency_agent import CurrencyAgent 7 | from mcpagentai.core.logging import get_logger 8 | 9 | class CurrencyQueryHandler(QueryHandler): 10 | def __init__(self): 11 | self.currency_agent = CurrencyAgent() 12 | self.logger = get_logger("mcpagentai.currency_handler") 13 | 14 | # Common currency aliases 15 | self.currency_aliases = { 16 | "dollar": "USD", 17 | "usd": "USD", 18 | "euro": "EUR", 19 | "eur": "EUR", 20 | "pound": "GBP", 21 | "gbp": "GBP", 22 | "yen": "JPY", 23 | "jpy": "JPY", 24 | "yuan": "CNY", 25 | "cny": "CNY", 26 | "cad": "CAD", 27 | "canadian dollar": "CAD", 28 | "canadian": "CAD", 29 | "bitcoin": "BTC", 30 | "btc": "BTC", 31 | "eth": "ETH" 32 | } 33 | 34 | @property 35 | def query_type(self) -> str: 36 | return "currency" 37 | 38 | @property 39 | def available_params(self) -> Dict[str, str]: 40 | return { 41 | "base_currency": "Source currency code or common name (e.g., 'USD', 'euro')", 42 | "target_currency": "Target currency code or common name (e.g., 'EUR', 'yen')", 43 | "amount": "Amount to convert (optional, defaults to 1)" 44 | } 45 | 46 | def handle_query(self, params: Dict[str, Any]) -> Optional[str]: 47 | try: 48 | # Get currency codes from params 49 | base = params.get("base_currency", "").lower() 50 | target = params.get("target_currency", "").lower() 51 | amount = float(params.get("amount", 1)) 52 | 53 | # Convert aliases to codes 54 | base_code = self.currency_aliases.get(base, base.upper()) 55 | target_code = self.currency_aliases.get(target, target.upper()) 56 | 57 | # Default to USD->EUR if no currencies provided 58 | base_code = base_code or "USD" 59 | target_code = target_code or "EUR" 60 | 61 | # Get conversion data 62 | conversion = self.currency_agent.call_tool( 63 | CurrencyTools.CONVERT_CURRENCY.value, 64 | { 65 | "base_currency": base_code, 66 | "target_currency": target_code, 67 | "amount": amount 68 | } 69 | ) 70 | 71 | if conversion and conversion[0].text: 72 | result = json.loads(conversion[0].text) 73 | if "converted_amount" in result: 74 | formatted_amount = "{:.2f}".format(float(result["converted_amount"])) 75 | rate = float(result["converted_amount"]) / amount 76 | formatted_rate = "{:.4f}".format(rate) 77 | 78 | # Build a more informative response 79 | response = [ 80 | f"💱 Exchange Rate: 1 {base_code} = {formatted_rate} {target_code}", 81 | f"🔄 Conversion: {amount:,.2f} {base_code} = {formatted_amount} {target_code}" 82 | ] 83 | 84 | # Add date if available 85 | if "date" in result and result["date"] != "unknown": 86 | response.append(f"📅 As of: {result['date']}") 87 | 88 | return "\n".join(response) 89 | 90 | return None 91 | 92 | except Exception as e: 93 | self.logger.error(f"Error in currency handler: {e}") 94 | return None 95 | 96 | @property 97 | def examples(self) -> Dict[str, str]: 98 | return { 99 | "Convert 100 USD to EUR": {"base_currency": "usd", "target_currency": "eur", "amount": 100}, 100 | "How much is 50 euro in yen?": {"base_currency": "euro", "target_currency": "yen", "amount": 50}, 101 | "Bitcoin price in USD": {"base_currency": "btc", "target_currency": "usd", "amount": 1}, 102 | "Convert 1000 yuan to dollars": {"base_currency": "cny", "target_currency": "usd", "amount": 1000} 103 | } -------------------------------------------------------------------------------- /src/mcpagentai/tools/twitter/handlers/weather_handler.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Dict, Any, Optional, List 3 | 4 | from mcpagentai.tools.twitter.query_handler import QueryHandler 5 | from mcpagentai.defs import WeatherTools 6 | from mcpagentai.tools.weather_agent import WeatherAgent 7 | from mcpagentai.core.logging import get_logger 8 | 9 | class WeatherQueryHandler(QueryHandler): 10 | def __init__(self): 11 | super().__init__() 12 | self.weather_agent = WeatherAgent() 13 | self.logger = get_logger("mcpagentai.weather_handler") 14 | 15 | # Common city coordinates 16 | self.city_coords = { 17 | 'sf': '37.7749,-122.4194', 18 | 'nyc': '40.7128,-74.0060', 19 | 'london': '51.5074,-0.1278', 20 | 'tokyo': '35.6762,139.6503', 21 | 'paris': '48.8566,2.3522', 22 | 'berlin': '52.5200,13.4050', 23 | 'osaka': '34.6937,135.5023', # Added Osaka 24 | 'singapore': '1.3521,103.8198', 25 | 'sydney': '-33.8688,151.2093', 26 | 'dubai': '25.2048,55.2708', 27 | 'mumbai': '19.0760,72.8777', 28 | 'seoul': '37.5665,126.9780', 29 | 'hong kong': '22.3193,114.1694' # Added Hong Kong 30 | } 31 | 32 | @property 33 | def query_type(self) -> str: 34 | return "weather" 35 | 36 | @property 37 | def available_params(self) -> Dict[str, Any]: 38 | return { 39 | "city": "City name (e.g. 'sf', 'nyc', 'london', 'tokyo', etc.)", 40 | "location": "Coordinates in 'lat,lon' format (e.g. '52.52,13.41')" 41 | } 42 | 43 | def handle_query(self, params: Dict[str, Any]) -> Optional[str]: 44 | try: 45 | location = None 46 | 47 | # Check for city parameter first 48 | if "city" in params: 49 | city = params["city"].lower() 50 | # Try to find city in our predefined coordinates 51 | if city in self.city_coords: 52 | location = self.city_coords[city] 53 | else: 54 | # If city not found, default to San Francisco 55 | self.logger.warning(f"City '{city}' not found in coordinates list, defaulting to San Francisco") 56 | location = self.city_coords['sf'] 57 | 58 | # If no city provided, check for direct coordinates 59 | elif "location" in params: 60 | location = params["location"] 61 | 62 | # Default to San Francisco if no location specified 63 | else: 64 | location = self.city_coords['sf'] 65 | 66 | # Get weather data 67 | weather_data = self.weather_agent.call_tool( 68 | WeatherTools.GET_CURRENT_WEATHER.value, 69 | {"location": location} 70 | ) 71 | 72 | if isinstance(weather_data, list) and len(weather_data) > 0: 73 | try: 74 | # Parse the JSON response 75 | data = json.loads(weather_data[0].text) 76 | 77 | # Extract temperature and description 78 | temp = data.get("temperature", 0.0) 79 | desc = data.get("description", "unknown conditions") 80 | 81 | # Format temperature to one decimal place 82 | temp_formatted = "{:.1f}".format(float(temp)) 83 | 84 | # Return formatted weather string 85 | return f"{temp_formatted}°F, {desc}" 86 | 87 | except (json.JSONDecodeError, KeyError) as e: 88 | self.logger.error(f"Error parsing weather data: {str(e)}") 89 | return "Error: Could not parse weather data" 90 | 91 | return "Error: No weather data available" 92 | 93 | except Exception as e: 94 | self.logger.error(f"Error in weather handler: {str(e)}") 95 | return f"Error in weather handler: {str(e)}" 96 | 97 | @property 98 | def examples(self) -> List[Dict[str, Any]]: 99 | return [ 100 | { 101 | "query": "What's the weather in Tokyo?", 102 | "params": {"city": "tokyo"} 103 | }, 104 | { 105 | "query": "How's the weather in San Francisco?", 106 | "params": {"city": "sf"} 107 | }, 108 | { 109 | "query": "Weather at coordinates 52.52,13.41", 110 | "params": {"location": "52.52,13.41"} 111 | } 112 | ] -------------------------------------------------------------------------------- /src/mcpagentai/tools/stock_agent.py: -------------------------------------------------------------------------------- 1 | from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource 2 | 3 | from mcpagentai.core.agent_base import MCPAgent 4 | from mcpagentai.defs import StockTools, StockGetPrice, StockGetTickerByNameAgent, StockGetPriceHistory 5 | 6 | from typing import Sequence, Union 7 | 8 | import requests 9 | 10 | import json 11 | 12 | 13 | class StockAgent(MCPAgent): 14 | def list_tools(self) -> list[Tool]: 15 | return [ 16 | Tool(name=StockTools.GET_TICKER_BY_NAME.value, 17 | description="Get list of stock tickers by keyword", 18 | inputSchema={"type": "object", 19 | "properties": { 20 | "keyword": { 21 | "type": "string", 22 | "description": "Keyword of stock name" 23 | }, 24 | "required": ["keyword"] 25 | } 26 | } 27 | ), 28 | Tool(name=StockTools.GET_STOCK_PRICE_TODAY.value, 29 | description="Get last stock price", 30 | inputSchema={ 31 | "type": "object", 32 | "properties": 33 | { 34 | "ticker": 35 | { 36 | "type": "string", 37 | "description": "Ticker of stock" 38 | }, 39 | "required": ["ticker"] 40 | } 41 | }), 42 | Tool(name=StockTools.GET_STOCK_PRICE_HISTORY.value, 43 | description="Get history of stock price", 44 | inputSchema={ 45 | "type": "object", 46 | "properties": 47 | { 48 | "ticker": 49 | { 50 | "type": "string", 51 | "description": "Ticker of stock" 52 | }, 53 | "required": ["ticker"] 54 | } 55 | }) 56 | ] 57 | 58 | def call_tool(self, 59 | name: str, 60 | arguments: dict) -> Sequence[Union[TextContent, ImageContent, EmbeddedResource]]: 61 | if name == StockTools.GET_TICKER_BY_NAME.value: 62 | return self._handle_get_ticker_by_name(arguments) 63 | elif name == StockTools.GET_STOCK_PRICE_TODAY.value: 64 | return self._handle_get_stock_price_today(arguments) 65 | elif name == StockTools.GET_STOCK_PRICE_HISTORY.value: 66 | return self._handle_get_stock_price_history(arguments) 67 | else: 68 | raise ValueError(f"Unknown tool value: {name}") 69 | 70 | def _handle_get_ticker_by_name(self, arguments: dict) -> Sequence[TextContent]: 71 | keyword = arguments.get("keyword") 72 | result = self._get_ticker_by_name(keyword) 73 | return [ 74 | TextContent(type="text", text=json.dumps(result.model_dump(), indent=2)) 75 | ] 76 | 77 | def _handle_get_stock_price_today(self, arguments: dict) -> Sequence[TextContent]: 78 | ticker = arguments.get("ticker") 79 | result = self._get_stock_price_today(ticker) 80 | return [ 81 | TextContent(type="text", text=json.dumps(result.model_dump(), indent=2)) 82 | ] 83 | 84 | def _handle_get_stock_price_history(self, arguments: dict) -> Sequence[TextContent]: 85 | ticker = arguments.get("ticker") 86 | result = self._get_stock_price_history(ticker) 87 | return [ 88 | TextContent(type="text", text=json.dumps(result.model_dump(), indent=2)) 89 | ] 90 | 91 | def _get_ticker_by_name(self, ticker: str) -> StockGetTickerByNameAgent: 92 | # todo add request success error handling 93 | url = f"https://www.alphavantage.co/query?function=SYMBOL_SEARCH&keywords={ticker}&apikey=demo%27)" 94 | response = requests.get(url) 95 | data = response.json() 96 | return StockGetTickerByNameAgent(tickers=data['bestMatches']) 97 | 98 | def _get_stock_price_today(self, ticker: str) -> StockGetPrice: 99 | url = f"https://www.alphavantage.co/query?function=TIME_SERIES_DAILY&symbol={ticker}&apikey=demo" 100 | response = requests.get(url) 101 | data = response.json() 102 | price_series = data['Time Series (Daily)'] 103 | last_day = next(iter(price_series)) 104 | return StockGetPrice(price=price_series[last_day]['4. close']) 105 | 106 | def _get_stock_price_history(self, ticker: str) -> StockGetPriceHistory: 107 | url = f"https://www.alphavantage.co/query?function=TIME_SERIES_DAILY&symbol={ticker}&apikey=demo" 108 | response = requests.get(url) 109 | data = response.json() 110 | price_series = data['Time Series (Daily)'] 111 | return StockGetPriceHistory(prices=price_series) -------------------------------------------------------------------------------- /src/mcpagentai/defs.py: -------------------------------------------------------------------------------- 1 | """ 2 | Shared data models used across multiple tools (pydantic BaseModels, Enums, etc.). 3 | """ 4 | 5 | from enum import Enum 6 | from pydantic import BaseModel 7 | from typing import List, Dict, Optional 8 | 9 | 10 | # ------------------------------------------------------------------------- 11 | # TIME MODELS (example if you have them) 12 | # ------------------------------------------------------------------------- 13 | class TimeTools(str, Enum): 14 | GET_CURRENT_TIME = "get_current_time" 15 | CONVERT_TIME = "convert_time" 16 | 17 | class TimeResult(BaseModel): 18 | timezone: str 19 | datetime: str 20 | is_dst: bool 21 | 22 | class TimeConversionResult(BaseModel): 23 | source: TimeResult 24 | target: TimeResult 25 | time_difference: str 26 | 27 | 28 | # ------------------------------------------------------------------------- 29 | # WEATHER MODELS (example if you have them) 30 | # ------------------------------------------------------------------------- 31 | class WeatherTools(str, Enum): 32 | GET_CURRENT_WEATHER = "get_current_weather" 33 | FORECAST = "get_weather_forecast" 34 | 35 | class CurrentWeatherResult(BaseModel): 36 | location: str 37 | temperature: float 38 | description: str 39 | 40 | class WeatherForecastResult(BaseModel): 41 | location: str 42 | forecast: List[Dict] 43 | 44 | 45 | # ------------------------------------------------------------------------- 46 | # CURRENCY MODELS (example if you have them) 47 | # ------------------------------------------------------------------------- 48 | class CurrencyTools(str, Enum): 49 | GET_EXCHANGE_RATE = "get_exchange_rate" 50 | CONVERT_CURRENCY = "convert_currency" 51 | 52 | class ExchangeRateResult(BaseModel): 53 | base: str 54 | rates: Dict[str, float] 55 | date: str 56 | 57 | class ConversionResult(BaseModel): 58 | base: str 59 | target: str 60 | amount: float 61 | converted_amount: float 62 | date: str 63 | 64 | 65 | # ------------------------------------------------------------------------- 66 | # ELIZA MODELS & ENUMS (Remote HTTP-based) 67 | # ------------------------------------------------------------------------- 68 | class ElizaTools(str, Enum): 69 | GET_AGENTS = "get_eliza_agents" 70 | MESSAGE_AGENT = "message_eliza_agent" 71 | 72 | class ElizaGetAgents(BaseModel): 73 | """Returns a list of agent names from a remote Eliza server.""" 74 | agents: List[str] 75 | 76 | class ElisaMessageAgent(BaseModel): 77 | """Returns a single message from a specific Eliza agent.""" 78 | agent_message: str 79 | 80 | 81 | # ------------------------------------------------------------------------- 82 | # ELIZA PARSER (Local file-based) MODELS & ENUMS 83 | # ------------------------------------------------------------------------- 84 | class ElizaParserTools(str, Enum): 85 | GET_CHARACTERS = "get_characters" 86 | GET_CHARACTER_BIO = "get_character_bio" 87 | GET_CHARACTER_LORE = "get_character_lore" 88 | GET_FULL_AGENT_INFO = "get_full_agent_info" 89 | INTERACT_WITH_AGENT = "interact_with_agent" 90 | 91 | class ElizaGetCharacters(BaseModel): 92 | """List of local character JSON files.""" 93 | characters: List[str] 94 | 95 | class ElizaGetCharacterBio(BaseModel): 96 | """Bio content for a single character.""" 97 | characters: str 98 | 99 | class ElizaGetCharacterLore(BaseModel): 100 | """Lore content for a single character.""" 101 | characters: str 102 | 103 | 104 | # -- STOCK MODELS ------------------------------------------ # 105 | 106 | class StockTools(str, Enum): 107 | GET_TICKER_BY_NAME = "get_ticker_by_name" 108 | GET_STOCK_PRICE_TODAY = "get_stock_price" 109 | GET_STOCK_PRICE_HISTORY = "get_stock_price_history" 110 | 111 | class StockGetTickerByNameAgent(BaseModel): 112 | tickers: List[str] 113 | 114 | class StockGetPrice(BaseModel): 115 | price: float 116 | 117 | class StockGetPriceHistory(BaseModel): 118 | prices: Dict 119 | 120 | # -- TWITTER MODELS ------------------------------------------ # 121 | 122 | class TwitterTools(str, Enum): 123 | CREATE_TWEET = "create_tweet" 124 | REPLY_TWEET = "reply_tweet" 125 | 126 | class TwitterResult(BaseModel): 127 | """ 128 | Minimal response model for tweet creation or reply. 129 | """ 130 | success: bool 131 | message: Optional[str] = None 132 | tweet_url: Optional[str] = None 133 | error: Optional[str] = None 134 | 135 | 136 | # ------------------------------------------------------------------------- 137 | # CRYPTO MODELS 138 | # ------------------------------------------------------------------------- 139 | class CryptoTools(str, Enum): 140 | GET_CRYPTO_PRICE = "get_crypto_price" 141 | GET_CRYPTO_INFO = "get_crypto_info" 142 | 143 | class CryptoPriceResult(BaseModel): 144 | symbol: str 145 | price_usd: float 146 | price_btc: Optional[float] 147 | market_cap: Optional[float] 148 | volume_24h: Optional[float] 149 | change_24h: Optional[float] 150 | last_updated: str 151 | 152 | class CryptoInfoResult(BaseModel): 153 | symbol: str 154 | name: str 155 | description: Optional[str] 156 | website: Optional[str] 157 | explorer: Optional[str] 158 | rank: Optional[int] 159 | total_supply: Optional[float] 160 | circulating_supply: Optional[float] -------------------------------------------------------------------------------- /src/mcpagentai/tools/crypto_agent.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import requests 4 | from typing import Sequence, Union 5 | 6 | from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource 7 | from mcpagentai.core.agent_base import MCPAgent 8 | from mcpagentai.defs import CryptoTools 9 | 10 | class CryptoAgent(MCPAgent): 11 | """ 12 | Agent that handles cryptocurrency functionality using CoinGecko API 13 | """ 14 | 15 | BASE_URL = "https://api.coingecko.com/api/v3" 16 | 17 | def __init__(self): 18 | super().__init__() 19 | 20 | # Common ID mappings (symbol -> coingecko_id) 21 | self.coin_ids = { 22 | "BTC": "bitcoin", 23 | "ETH": "ethereum", 24 | "SOL": "solana", 25 | "DOGE": "dogecoin", 26 | "ADA": "cardano", 27 | "XRP": "ripple", 28 | "DOT": "polkadot", 29 | "AVAX": "avalanche-2", 30 | "MATIC": "matic-network", 31 | "LINK": "chainlink" 32 | } 33 | 34 | def list_tools(self) -> list[Tool]: 35 | """List available crypto tools""" 36 | return [ 37 | Tool( 38 | name=CryptoTools.GET_CRYPTO_PRICE.value, 39 | description="Get current price and 24h change for a cryptocurrency", 40 | inputSchema={ 41 | "type": "object", 42 | "properties": { 43 | "symbol": { 44 | "type": "string", 45 | "description": "Cryptocurrency symbol (e.g., BTC, ETH)" 46 | } 47 | }, 48 | "required": ["symbol"] 49 | } 50 | ), 51 | Tool( 52 | name=CryptoTools.GET_CRYPTO_INFO.value, 53 | description="Get detailed information about a cryptocurrency", 54 | inputSchema={ 55 | "type": "object", 56 | "properties": { 57 | "symbol": { 58 | "type": "string", 59 | "description": "Cryptocurrency symbol (e.g., BTC, ETH)" 60 | } 61 | }, 62 | "required": ["symbol"] 63 | } 64 | ) 65 | ] 66 | 67 | def call_tool( 68 | self, 69 | name: str, 70 | arguments: dict 71 | ) -> Sequence[Union[TextContent, ImageContent, EmbeddedResource]]: 72 | """Route tool calls to appropriate handlers""" 73 | if name == CryptoTools.GET_CRYPTO_PRICE.value: 74 | return self._handle_get_price(arguments) 75 | elif name == CryptoTools.GET_CRYPTO_INFO.value: 76 | return self._handle_get_info(arguments) 77 | else: 78 | raise ValueError(f"Unknown tool: {name}") 79 | 80 | def _handle_get_price(self, arguments: dict) -> Sequence[TextContent]: 81 | """Get current price and 24h change for a cryptocurrency""" 82 | symbol = arguments.get("symbol", "").upper() 83 | 84 | # Get coin ID from symbol 85 | coin_id = self.coin_ids.get(symbol) 86 | if not coin_id: 87 | self.logger.error(f"Unknown cryptocurrency symbol: {symbol}") 88 | return [TextContent(type="text", text=json.dumps({"error": "Unknown cryptocurrency"}))] 89 | 90 | try: 91 | # Call CoinGecko API 92 | url = f"{self.BASE_URL}/simple/price" 93 | params = { 94 | "ids": coin_id, 95 | "vs_currencies": "usd", 96 | "include_24hr_change": "true" 97 | } 98 | 99 | self.logger.info(f"Fetching price data for {symbol} ({coin_id})") 100 | response = requests.get(url, params=params) 101 | data = response.json() 102 | 103 | if coin_id in data: 104 | result = { 105 | "symbol": symbol, 106 | "price_usd": data[coin_id]["usd"], 107 | "change_24h": data[coin_id].get("usd_24h_change") 108 | } 109 | return [TextContent(type="text", text=json.dumps(result))] 110 | else: 111 | self.logger.error(f"No price data found for {symbol}") 112 | return [TextContent(type="text", text=json.dumps({"error": "No price data found"}))] 113 | 114 | except Exception as e: 115 | self.logger.error(f"Error fetching price: {e}") 116 | return [TextContent(type="text", text=json.dumps({"error": str(e)}))] 117 | 118 | def _handle_get_info(self, arguments: dict) -> Sequence[TextContent]: 119 | """Get detailed information about a cryptocurrency""" 120 | symbol = arguments.get("symbol", "").upper() 121 | 122 | # Get coin ID from symbol 123 | coin_id = self.coin_ids.get(symbol) 124 | if not coin_id: 125 | self.logger.error(f"Unknown cryptocurrency symbol: {symbol}") 126 | return [TextContent(type="text", text=json.dumps({"error": "Unknown cryptocurrency"}))] 127 | 128 | try: 129 | # Call CoinGecko API 130 | url = f"{self.BASE_URL}/coins/{coin_id}" 131 | params = { 132 | "localization": "false", 133 | "tickers": "false", 134 | "market_data": "true", 135 | "community_data": "false", 136 | "developer_data": "false" 137 | } 138 | 139 | self.logger.info(f"Fetching info for {symbol} ({coin_id})") 140 | response = requests.get(url, params=params) 141 | data = response.json() 142 | 143 | result = { 144 | "symbol": symbol, 145 | "name": data["name"], 146 | "description": data["description"]["en"], 147 | "website": data["links"]["homepage"][0], 148 | "explorer": data["links"]["blockchain_site"][0], 149 | "rank": data["market_cap_rank"], 150 | "total_supply": data["market_data"]["total_supply"], 151 | "circulating_supply": data["market_data"]["circulating_supply"] 152 | } 153 | return [TextContent(type="text", text=json.dumps(result))] 154 | 155 | except Exception as e: 156 | self.logger.error(f"Error fetching info: {e}") 157 | return [TextContent(type="text", text=json.dumps({"error": str(e)}))] -------------------------------------------------------------------------------- /src/mcpagentai/tools/eliza/agent.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import logging 4 | import requests 5 | import subprocess 6 | import time 7 | import socket 8 | 9 | from typing import Sequence, Union 10 | 11 | from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource, ErrorData 12 | from mcp.shared.exceptions import McpError 13 | 14 | from mcpagentai.core.agent_base import MCPAgent 15 | from mcpagentai.defs import ElizaTools, ElizaGetAgents, ElisaMessageAgent 16 | 17 | 18 | 19 | class ElizaAgent(MCPAgent): 20 | """ 21 | Communicates with a remote Eliza server over HTTP. 22 | """ 23 | 24 | def __init__(self): 25 | super().__init__() 26 | # Use .env or fallback default if not provided 27 | self.eliza_api_url = os.getenv("ELIZA_API_URL") 28 | self.eliza_path = os.getenv("ELIZA_PATH") 29 | self.logger.info("ElizaAgent initialized with API URL: %s", self.eliza_api_url) 30 | 31 | def list_tools(self) -> list[Tool]: 32 | return [ 33 | Tool( 34 | name=ElizaTools.GET_AGENTS.value, 35 | description="Get list of Eliza agents", 36 | inputSchema={ 37 | "type": "object", 38 | "properties": { 39 | "question": { 40 | "type": "string", 41 | "description": "Question to Eliza to list all agents" 42 | }, 43 | } 44 | } 45 | ), 46 | Tool( 47 | name=ElizaTools.MESSAGE_AGENT.value, 48 | description="Message specific Eliza agent and get response from it", 49 | inputSchema={ 50 | "type": "object", 51 | "properties": { 52 | "agent": { 53 | "type": "string", 54 | "description": "Name of agent from Eliza" 55 | }, 56 | "message": { 57 | "type": "string", 58 | "description": "Message to specific Eliza agent" 59 | }, 60 | }, 61 | "required": ["agent", "message"] 62 | } 63 | ), 64 | ] 65 | 66 | def call_tool( 67 | self, 68 | name: str, 69 | arguments: dict 70 | ) -> Sequence[Union[TextContent, ImageContent, EmbeddedResource]]: 71 | self.logger.debug("ElizaAgent call_tool => name=%s, arguments=%s", name, arguments) 72 | if name == ElizaTools.GET_AGENTS.value: 73 | return self._handle_get_agents(arguments) 74 | elif name == ElizaTools.MESSAGE_AGENT.value: 75 | return self._handle_message_agent(arguments) 76 | else: 77 | raise ValueError(f"Unknown tool value: {name}") 78 | 79 | def _handle_get_agents(self, arguments: dict) -> Sequence[TextContent]: 80 | question = arguments.get("question") or "list eliza agents" 81 | self.logger.debug("Handling GET_AGENTS with question=%s", question) 82 | result = self._get_agents(question) 83 | return [TextContent(type="text", text=json.dumps(result.model_dump(), indent=2))] 84 | 85 | def _handle_message_agent(self, arguments: dict) -> Sequence[TextContent]: 86 | agent = arguments.get("agent") 87 | message = arguments.get("message") 88 | self.logger.debug("Handling MESSAGE_AGENT with agent=%s, message=%s", agent, message) 89 | 90 | if agent is None: 91 | raise McpError(ErrorData(message="Agent name not provided", code=-1)) 92 | if message is None: 93 | raise McpError(ErrorData(message="Message to agent not provided", code=-1)) 94 | 95 | result = self._message_agent(agent, message) 96 | return [TextContent(type="text", text=json.dumps(result.model_dump(), indent=2))] 97 | 98 | def _get_agents_all_data(self) -> dict: 99 | agents_url = f"{self.eliza_api_url}/api/agents" 100 | self.logger.info("Fetching Eliza agents from: %s", agents_url) 101 | 102 | try: 103 | response = requests.get(agents_url) 104 | if response.status_code != 200: 105 | raise McpError(ErrorData(message="Message to agent not provided", code=-1)) 106 | except requests.RequestException as e: 107 | error_msg = f"Request error connecting to Eliza server: {str(e)}" 108 | self.logger.error(error_msg) 109 | raise McpError(ErrorData(message=error_msg, code=-1)) 110 | 111 | return response.json() 112 | 113 | def _get_agents(self, question: str) -> ElizaGetAgents: 114 | """ 115 | Return a pydantic model with the agent names from the Eliza server. 116 | """ 117 | agents_data = self._get_agents_all_data() 118 | agent_names = [agent['name'] for agent in agents_data.get('agents', [])] 119 | return ElizaGetAgents(agents=agent_names) 120 | 121 | def _message_agent(self, agent_name: str, message: str) -> ElisaMessageAgent: 122 | """ 123 | Send a message to a specific agent and return a pydantic model with the agent's response. 124 | """ 125 | agents_data = self._get_agents_all_data() 126 | agent_id = None 127 | for ag in agents_data.get("agents", []): 128 | if ag['name'] == agent_name: 129 | agent_id = ag['id'] 130 | break 131 | 132 | if agent_id is None: 133 | raise McpError(ErrorData(message=f"Couldn't find agent with name: {agent_name}", code=-1)) 134 | 135 | message_url = f"{self.eliza_api_url}/api/{agent_id}/message" 136 | if self.eliza_api_url.startswith("http://"): 137 | host_url = self.eliza_api_url[len("http://"):] 138 | elif self.eliza_api_url.startswith("https://"): 139 | host_url = self.eliza_api_url[len("https://"):] 140 | else: 141 | host_url = self.eliza_api_url 142 | 143 | headers = { 144 | "Accept": "*/*", 145 | "Sec-Fetch-Site": "same-origin", 146 | "Accept-Language": "pl-PL,pl;q=0.9", 147 | "Accept-Encoding": "gzip, deflate", 148 | "Sec-Fetch-Mode": "cors", 149 | "Host": host_url, 150 | "Origin": self.eliza_api_url, 151 | "User-Agent": "MCP-ElizaAgent", 152 | "Referer": f"{self.eliza_api_url}/{agent_id}/chat", 153 | "Connection": "keep-alive", 154 | "Sec-Fetch-Dest": "empty", 155 | } 156 | 157 | # form data => multipart/form-data 158 | files = { 159 | "text": (None, message), 160 | "userId": (None, "user"), 161 | "roomId": (None, f"default-room-{agent_id}"), 162 | } 163 | 164 | try: 165 | response = requests.post(message_url, headers=headers, files=files) 166 | if response.status_code != 200: 167 | raise McpError(ErrorData(message=f"Can't connect to Eliza server or invalid agent id parameter: {agent_id}", code=-1)) 168 | except requests.RequestException as e: 169 | error_msg = f"Request error posting to Eliza server: {str(e)}" 170 | self.logger.error(error_msg) 171 | raise McpError(ErrorData(message=error_msg, code=-1)) 172 | 173 | resp_json = response.json() 174 | agent_message = resp_json[0]["text"] if resp_json else "" 175 | return ElisaMessageAgent(agent_message=agent_message) 176 | -------------------------------------------------------------------------------- /src/mcpagentai/tools/currency_agent.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import requests 4 | from typing import Sequence, Union, Optional 5 | 6 | from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource 7 | from mcpagentai.core.agent_base import MCPAgent 8 | 9 | # Import your currency definitions from defs.py 10 | from mcpagentai.defs import ( 11 | CurrencyTools, 12 | ExchangeRateResult, 13 | ConversionResult 14 | ) 15 | 16 | 17 | class CurrencyAgent(MCPAgent): 18 | """ 19 | Agent that handles currency functionality: 20 | - retrieving latest exchange rates 21 | - converting an amount from one currency to another 22 | """ 23 | 24 | BASE_URL = "https://api.freecurrencyapi.com/v1/latest" 25 | 26 | def __init__(self, api_key: str | None = None): 27 | """ 28 | Initializes the CurrencyAgent with an API key. 29 | The API key can be passed directly or retrieved from the ACCESS_KEY environment variable. 30 | """ 31 | super().__init__() 32 | self.api_key = api_key or os.getenv("FREECURRENCY_API_KEY", "") 33 | if not self.api_key: 34 | raise ValueError("API key is missing! Set FREECURRENCY_API_KEY environment variable or pass it as an argument.") 35 | 36 | def list_tools(self) -> list[Tool]: 37 | """ 38 | Returns a list of Tools that this agent can handle, 39 | matching the patterns from defs.py 40 | """ 41 | return [ 42 | Tool( 43 | name=CurrencyTools.GET_EXCHANGE_RATE.value, 44 | description="Get latest exchange rates from a base currency to one or more target currencies", 45 | inputSchema={ 46 | "type": "object", 47 | "properties": { 48 | "base_currency": { 49 | "type": "string", 50 | "description": "The ISO currency code to use as the base (e.g., 'USD')", 51 | }, 52 | "symbols": { 53 | "type": "array", 54 | "items": {"type": "string"}, 55 | "description": "Array of target currency codes (e.g., ['EUR','GBP'])", 56 | }, 57 | }, 58 | "required": ["base_currency", "symbols"], 59 | }, 60 | ), 61 | Tool( 62 | name=CurrencyTools.CONVERT_CURRENCY.value, 63 | description="Convert an amount from one currency to another", 64 | inputSchema={ 65 | "type": "object", 66 | "properties": { 67 | "base_currency": { 68 | "type": "string", 69 | "description": "The ISO currency code to convert FROM (e.g., 'USD')", 70 | }, 71 | "target_currency": { 72 | "type": "string", 73 | "description": "The ISO currency code to convert INTO (e.g., 'EUR')", 74 | }, 75 | "amount": { 76 | "type": "number", 77 | "description": "Amount of money to convert", 78 | }, 79 | }, 80 | "required": ["base_currency", "target_currency", "amount"], 81 | }, 82 | ), 83 | ] 84 | 85 | def call_tool( 86 | self, 87 | name: str, 88 | arguments: dict 89 | ) -> Sequence[Union[TextContent, ImageContent, EmbeddedResource]]: 90 | """ 91 | Routes the tool call to the appropriate handler method. 92 | """ 93 | if name == CurrencyTools.GET_EXCHANGE_RATE.value: 94 | return self._handle_get_exchange_rate(arguments) 95 | elif name == CurrencyTools.CONVERT_CURRENCY.value: 96 | return self._handle_convert_currency(arguments) 97 | else: 98 | raise ValueError(f"Unknown tool: {name}") 99 | 100 | # ------------------------------------------------------------------- 101 | # Tool Handlers 102 | # ------------------------------------------------------------------- 103 | 104 | def _handle_get_exchange_rate(self, arguments: dict) -> Sequence[TextContent]: 105 | """ 106 | Retrieves the latest exchange rates from a base currency to one or more targets. 107 | """ 108 | base_currency = arguments.get("base_currency", "").upper() 109 | symbols = arguments.get("symbols", []) 110 | 111 | result = self._get_exchange_rate(base_currency, symbols) 112 | 113 | return [ 114 | TextContent( 115 | type="text", 116 | text=json.dumps(result, indent=2) 117 | ) 118 | ] 119 | 120 | def _handle_convert_currency(self, arguments: dict) -> Sequence[TextContent]: 121 | """ 122 | Converts a specific amount from a base currency to a target currency. 123 | """ 124 | base_currency = arguments.get("base_currency", "").upper() 125 | target_currency = arguments.get("target_currency", "").upper() 126 | amount = arguments.get("amount", 1) 127 | 128 | # Ensure amount is a float 129 | amount = float(amount) 130 | 131 | result = self._convert_currency(base_currency, target_currency, amount) 132 | 133 | return [ 134 | TextContent( 135 | type="text", 136 | text=json.dumps(result.model_dump(), indent=2) 137 | ) 138 | ] 139 | 140 | # ------------------------------------------------------------------- 141 | # Internal Methods (API calls, data processing, etc.) 142 | # ------------------------------------------------------------------- 143 | 144 | def _get_exchange_rate(self, base_currency: str, symbols: list[str]) -> dict: 145 | """ 146 | Calls FreeCurrencyAPI to retrieve exchange rates. 147 | """ 148 | symbols_str = ",".join(symbols) 149 | params = { 150 | "apikey": self.api_key, 151 | "base_currency": base_currency, 152 | "currencies": symbols_str 153 | } 154 | 155 | try: 156 | resp = requests.get(self.BASE_URL, params=params) 157 | data = resp.json() 158 | 159 | if "error" in data: 160 | raise ValueError(f"API error: {data['error']}") 161 | 162 | rates = data.get("data", {}) 163 | return { 164 | "base": base_currency, 165 | "rates": rates, 166 | "timestamp": data.get("timestamp") 167 | } 168 | except Exception as e: 169 | raise RuntimeError(f"Error calling exchange rate API: {e}") 170 | 171 | def _convert_currency(self, base_currency: str, target_currency: str, amount: float) -> ConversionResult: 172 | """ 173 | Uses FreeCurrencyAPI rates to calculate currency conversion. 174 | """ 175 | exchange_rates = self._get_exchange_rate(base_currency, [target_currency]) 176 | rates = exchange_rates.get("rates", {}) 177 | rate = rates.get(target_currency) 178 | 179 | if not rate: 180 | raise ValueError(f"Conversion rate for {target_currency} not found.") 181 | 182 | converted_amount = amount * rate 183 | timestamp = exchange_rates.get("timestamp") 184 | 185 | # Ensure the date is a valid string 186 | if timestamp: 187 | try: 188 | from datetime import datetime 189 | date = datetime.utcfromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S") 190 | except Exception: 191 | date = "unknown" 192 | else: 193 | date = "unknown" 194 | 195 | return ConversionResult( 196 | base=base_currency, 197 | target=target_currency, 198 | amount=amount, 199 | converted_amount=converted_amount, 200 | date=date 201 | ) 202 | 203 | -------------------------------------------------------------------------------- /src/mcpagentai/tools/eliza/scripts/run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | # -------------------------------------------- 4 | # Color Definitions 5 | # -------------------------------------------- 6 | 7 | # ANSI color codes 8 | RED='\033[0;31m' 9 | GREEN='\033[0;32m' 10 | YELLOW='\033[0;33m' 11 | BLUE='\033[0;34m' 12 | MAGENTA='\033[0;35m' 13 | CYAN='\033[0;36m' 14 | BOLD='\033[1m' 15 | NC='\033[0m' # No Color 16 | 17 | # -------------------------------------------- 18 | # Function Definitions 19 | # -------------------------------------------- 20 | 21 | # Function to log messages with timestamp and color 22 | log() { 23 | local level="$1" 24 | shift 25 | local message="$*" 26 | 27 | case "$level" in 28 | INFO) 29 | echo -e "${GREEN}[INFO] $(date '+%Y-%m-%d %H:%M:%S'):${NC} $message" 30 | ;; 31 | WARN) 32 | echo -e "${YELLOW}[WARN] $(date '+%Y-%m-%d %H:%M:%S'):${NC} $message" 33 | ;; 34 | ERROR) 35 | echo -e "${RED}[ERROR] $(date '+%Y-%m-%d %H:%M:%S'):${NC} $message" 36 | ;; 37 | SUCCESS) 38 | echo -e "${BLUE}[SUCCESS] $(date '+%Y-%m-%d %H:%M:%S'):${NC} $message" 39 | ;; 40 | DEBUG) 41 | echo -e "${CYAN}[DEBUG] $(date '+%Y-%m-%d %H:%M:%S'):${NC} $message" 42 | ;; 43 | *) 44 | echo -e "${NC}[UNKNOWN] $(date '+%Y-%m-%d %H:%M:%S'):${NC} $message" 45 | ;; 46 | esac 47 | } 48 | 49 | # Function to check if a specific port is in use 50 | is_port_in_use() { 51 | local port=$1 52 | if lsof -i :"$port" >/dev/null 2>&1; then 53 | return 0 # Port is in use 54 | else 55 | return 1 # Port is free 56 | fi 57 | } 58 | 59 | # Function to list all .json files in characters/ directory and join them with commas 60 | get_characters_argument() { 61 | local dir="characters" 62 | local files=("$dir"/*.json) 63 | 64 | # Check if there are any .json files 65 | if [ ! -e "${files[0]}" ]; then 66 | log "ERROR" "No .json files found in $dir directory." 67 | exit 1 68 | fi 69 | 70 | # Join filenames with commas 71 | local characters="" 72 | for file in "${files[@]}"; do 73 | if [ -z "$characters" ]; then 74 | characters="$file" 75 | else 76 | characters="$characters,$file" 77 | fi 78 | done 79 | 80 | echo "$characters" 81 | } 82 | 83 | # Function to start a background process using pnpm with arguments 84 | start_pnpm_process() { 85 | local script="$1" # Script name (e.g., start, start:client) 86 | local log_file="$2" # Log file (e.g., server.log) 87 | local port="$3" # Port number (e.g., 3000) 88 | shift 3 89 | local args=("$@") # Additional arguments (e.g., --characters=...) 90 | 91 | if is_port_in_use "$port"; then 92 | log "WARN" "Port $port is already in use. Skipping start of process '$script'." 93 | else 94 | if [ ${#args[@]} -eq 0 ]; then 95 | log "INFO" "Starting process '$script' on port $port without additional arguments." 96 | else 97 | # Join the args array into a single string for logging 98 | log "INFO" "Starting process '$script' on port $port with arguments: ${args[*]}" 99 | fi 100 | 101 | # Use 'pnpm run' to execute scripts defined in package.json 102 | # '--' separates pnpm arguments from script arguments 103 | nohup pnpm run "$script" "${args[@]}" > "$log_file" 2>&1 & 104 | local pid=$! 105 | echo "$pid" > "${log_file%.log}.pid" 106 | log "DEBUG" "Process '$script' started with PID $pid. Logs: $log_file" 107 | 108 | log "DEBUG" "Sleeping for 15 seconds to allow process to start." 109 | sleep 15 # Allow some time for the process to start 110 | 111 | if is_port_in_use "$port"; then 112 | log "SUCCESS" "Process '$script' is running successfully on port $port." 113 | else 114 | log "ERROR" "Failed to start process '$script' on port $port. Check $log_file for details." 115 | exit 1 116 | fi 117 | fi 118 | } 119 | 120 | # Function to handle shutdown signals 121 | shutdown() { 122 | log "INFO" "◎ Received shutdown signal, closing server and client..." 123 | 124 | # Stop server 125 | if [ -f "server.pid" ]; then 126 | local server_pid 127 | server_pid=$(cat "server.pid") 128 | if kill "$server_pid" >/dev/null 2>&1; then 129 | log "INFO" "Sent SIGTERM to server (PID $server_pid)." 130 | else 131 | log "WARN" "Failed to send SIGTERM to server (PID $server_pid)." 132 | fi 133 | fi 134 | 135 | # Stop client 136 | if [ -f "client.pid" ]; then 137 | local client_pid 138 | client_pid=$(cat "client.pid") 139 | if kill "$client_pid" >/dev/null 2>&1; then 140 | log "INFO" "Sent SIGTERM to client (PID $client_pid)." 141 | else 142 | log "WARN" "Failed to send SIGTERM to client (PID $client_pid)." 143 | fi 144 | fi 145 | 146 | # Wait for processes to terminate 147 | sleep 3 148 | 149 | # Verify if processes are terminated 150 | if [ -f "server.pid" ] && kill -0 "$(cat "server.pid")" >/dev/null 2>&1; then 151 | log "WARN" "Server (PID $(cat server.pid)) did not terminate. Sending SIGKILL..." 152 | kill -9 "$(cat server.pid)" || true 153 | fi 154 | 155 | if [ -f "client.pid" ] && kill -0 "$(cat "client.pid")" >/dev/null 2>&1; then 156 | log "WARN" "Client (PID $(cat client.pid)) did not terminate. Sending SIGKILL..." 157 | kill -9 "$(cat client.pid)" || true 158 | fi 159 | 160 | log "SUCCESS" "✓ Server and client closed successfully." 161 | exit 0 162 | } 163 | 164 | # Function to check if required commands are available 165 | check_dependencies() { 166 | for cmd in pnpm lsof; do 167 | if ! command -v "$cmd" &> /dev/null; then 168 | log "ERROR" "Required command '$cmd' is not installed." 169 | exit 1 170 | fi 171 | done 172 | } 173 | 174 | # -------------------------------------------- 175 | # Script Execution 176 | # -------------------------------------------- 177 | 178 | # Set trap for SIGINT and SIGTERM to handle graceful shutdown 179 | trap shutdown SIGINT SIGTERM 180 | 181 | # Check for required dependencies 182 | check_dependencies 183 | 184 | # Verify that ELIZA_PATH environment variable is set 185 | if [ -z "${ELIZA_PATH:-}" ]; then 186 | log "ERROR" "ELIZA_PATH environment variable is not set." 187 | exit 1 188 | fi 189 | 190 | # Navigate to the ELIZA_PATH directory 191 | cd "$ELIZA_PATH" 192 | export NVM_DIR="$HOME/.nvm" 193 | [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" 194 | nvm use 23.3.0 195 | log "INFO" "Navigated to ELIZA_PATH: $ELIZA_PATH" 196 | 197 | # Get characters argument 198 | characters_value=$(get_characters_argument) 199 | 200 | log "INFO" "Loaded characters: ${characters_value//,/ , }" 201 | 202 | # Start Eliza Server on port 3000 203 | # Ensure that your package.json has a script named "start" that accepts the --characters argument 204 | start_pnpm_process "start" "server.log" 3000 "--characters=\"${characters_value}\"" 205 | 206 | # Start Eliza Client on port 5173 207 | # Ensure that your package.json has a script named "start:client" 208 | start_pnpm_process "start:client" "client.log" 5173 "" 209 | 210 | # Final Log Messages 211 | log "INFO" "==================================================" 212 | log "INFO" "Eliza server and client processes started in background." 213 | log "INFO" " - Server logs: $ELIZA_PATH/server.log" 214 | log "INFO" " - Client logs: $ELIZA_PATH/client.log" 215 | log "INFO" " - Loaded Agents: ${characters_value//,/ , }" 216 | log "INFO" "Check with check_server.sh and check_client.sh scripts." 217 | log "INFO" "==================================================" 218 | 219 | # Keep the script running to handle signals 220 | # This can be done using 'wait' if you want to wait for background processes, 221 | # but since we have PID files, we'll use an infinite loop 222 | while true; do 223 | sleep 1 224 | done 225 | -------------------------------------------------------------------------------- /src/mcpagentai/tools/twitter/agent_client_wrapper.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import subprocess 4 | from typing import Any, Dict, Union 5 | from pathlib import Path 6 | 7 | # Get current working directory (where start.sh is run from) 8 | WORKING_DIR = Path.cwd() 9 | COOKIES_PATH = WORKING_DIR / 'cookies.json' 10 | 11 | 12 | def _run_node_script(script_content: str) -> Union[Dict[str, Any], str]: 13 | """ 14 | Runs a Node.js script via a temporary file, captures stdout/stderr, 15 | and returns JSON (if possible) or raw text. 16 | """ 17 | script_filename = "temp_twitter_script.js" 18 | with open(script_filename, "w", encoding="utf-8") as f: 19 | f.write(script_content) 20 | 21 | process = subprocess.Popen( 22 | ["node", script_filename], 23 | stdout=subprocess.PIPE, 24 | stderr=subprocess.PIPE, 25 | env={**os.environ}, # Pass environment variables if needed 26 | ) 27 | stdout, stderr = process.communicate() 28 | retcode = process.returncode 29 | # Cleanup 30 | try: 31 | os.remove(script_filename) 32 | except OSError: 33 | pass 34 | if retcode != 0: 35 | err_msg = stderr.decode(errors="replace") 36 | raise RuntimeError(f"Node script error: {err_msg}") 37 | 38 | output_str = stdout.decode(errors="replace") 39 | try: 40 | return json.loads(output_str) 41 | except json.JSONDecodeError: 42 | return output_str 43 | 44 | 45 | def send_tweet(tweet_text: str) -> Dict[str, Any]: 46 | """ 47 | Sends a tweet using your Node.js agent-twitter-client approach. 48 | Returns the raw response as a dictionary (success, error, etc.). 49 | """ 50 | escaped_text = tweet_text.replace('"', '\\"') 51 | 52 | script = f""" 53 | const {{ Scraper }} = require('agent-twitter-client'); 54 | const {{ Cookie }} = require('tough-cookie'); 55 | 56 | (async function() {{ 57 | try {{ 58 | const scraper = new Scraper(); 59 | // Load cookies if they exist 60 | let cookiesData = []; 61 | try {{ 62 | cookiesData = require('{COOKIES_PATH}'); 63 | }} catch (err) {{ 64 | // no cookies - will trigger fresh login 65 | }} 66 | 67 | const cookies = cookiesData.map(c => new Cookie({{ 68 | key: c.key, value: c.value, domain: c.domain, 69 | path: c.path, secure: c.secure, httpOnly: c.httpOnly 70 | }}).toString()); 71 | await scraper.setCookies(cookies); 72 | 73 | // Post tweet 74 | const resp = await scraper.sendTweet("{escaped_text}"); 75 | 76 | // Save cookies after successful operation 77 | const newCookies = await scraper.getCookies(); 78 | require('fs').writeFileSync('{COOKIES_PATH}', JSON.stringify(newCookies, null, 2)); 79 | 80 | console.log(JSON.stringify({{ 81 | success: true, 82 | message: "Tweet posted!", 83 | tweet_url: resp.url ? resp.url : null 84 | }})); 85 | }} catch (error) {{ 86 | console.log(JSON.stringify({{ 87 | success: false, 88 | error: error.message 89 | }})); 90 | }} 91 | }})(); 92 | """ 93 | return _dictify(_run_node_script(script)) 94 | 95 | 96 | def reply_tweet(reply_text: str, tweet_url: str) -> Dict[str, Any]: 97 | """ 98 | Replies to a tweet given its URL or ID. 99 | In your Node.js, you might do `scraper.replyTweet(id, text)`. 100 | """ 101 | # If you need just the tweet ID, extract it from the URL 102 | tweet_id = extract_tweet_id(tweet_url) 103 | escaped_text = reply_text.replace('"', '\\"') 104 | 105 | script = f""" 106 | const {{ Scraper }} = require('agent-twitter-client'); 107 | const {{ Cookie }} = require('tough-cookie'); 108 | 109 | (async function() {{ 110 | try {{ 111 | const scraper = new Scraper(); 112 | // Load cookies from cookies.json 113 | let cookiesData = []; 114 | try {{ 115 | cookiesData = require('{COOKIES_PATH}'); 116 | }} catch (err) {{}} 117 | 118 | const cookies = cookiesData.map(c => new Cookie({{ 119 | key: c.key, value: c.value, domain: c.domain, 120 | path: c.path, secure: c.secure, httpOnly: c.httpOnly 121 | }}).toString()); 122 | await scraper.setCookies(cookies); 123 | 124 | // Post reply 125 | const resp = await scraper.replyTweet("{tweet_id}", "{escaped_text}"); 126 | 127 | // Save cookies after successful operation 128 | const newCookies = await scraper.getCookies(); 129 | require('fs').writeFileSync('{COOKIES_PATH}', JSON.stringify(newCookies, null, 2)); 130 | 131 | console.log(JSON.stringify({{ 132 | success: true, 133 | message: "Reply posted!", 134 | tweet_url: resp.url ? resp.url : null 135 | }})); 136 | }} catch (error) {{ 137 | console.log(JSON.stringify({{ 138 | success: false, 139 | error: error.message 140 | }})); 141 | }} 142 | }})(); 143 | """ 144 | 145 | return _dictify(_run_node_script(script)) 146 | 147 | 148 | def extract_tweet_id(url: str) -> str: 149 | """ 150 | Extract numeric tweet ID from a typical link: 151 | https://twitter.com/username/status/1234567890123456789 152 | """ 153 | if "/status/" in url: 154 | return url.rsplit("/status/", 1)[-1].split("?")[0] 155 | return url # fallback if already an ID or unknown format 156 | 157 | 158 | def _dictify(result: Any) -> Dict[str, Any]: 159 | """Ensure we return a dictionary even if the Node script yields raw text.""" 160 | if isinstance(result, dict): 161 | return result 162 | return {"success": False, "error": f"Unexpected output: {result}"} 163 | 164 | 165 | def get_mentions() -> list: 166 | """ 167 | Fetches recent mentions using searchTweets. 168 | Returns a list of mention objects with id, username, text, and conversation context. 169 | """ 170 | print("\n🔍 Checking for new mentions...") 171 | 172 | script = f""" 173 | const {{ Scraper }} = require('agent-twitter-client'); 174 | const {{ Cookie }} = require('tough-cookie'); 175 | 176 | (async function() {{ 177 | try {{ 178 | const scraper = new Scraper(); 179 | console.log("📝 Loading cookies..."); 180 | // Load cookies if they exist 181 | let cookiesData = []; 182 | try {{ 183 | cookiesData = require('{COOKIES_PATH}'); 184 | console.log("✅ Cookies loaded successfully"); 185 | }} catch (err) {{ 186 | console.log("⚠️ No existing cookies found"); 187 | }} 188 | 189 | const cookies = cookiesData.map(c => new Cookie({{ 190 | key: c.key, value: c.value, domain: c.domain, 191 | path: c.path, secure: c.secure, httpOnly: c.httpOnly 192 | }}).toString()); 193 | await scraper.setCookies(cookies); 194 | 195 | console.log(`🔎 Searching for tweets mentioning @${{process.env.TWITTER_USERNAME}}...`); 196 | const mentions = []; 197 | for await (const mention of scraper.searchTweets( 198 | `@${{process.env.TWITTER_USERNAME}}`, 199 | 100, 200 | 1 201 | )) {{ 202 | if (mention.username === process.env.TWITTER_USERNAME) {{ 203 | console.log(`⏩ Skipping own tweet: ${mention.text}`); 204 | continue; 205 | }} 206 | console.log(`✨ Found mention from @${mention.username}: ${mention.text}`); 207 | mentions.push(mention); 208 | }} 209 | 210 | const newCookies = await scraper.getCookies(); 211 | require('fs').writeFileSync('{COOKIES_PATH}', JSON.stringify(newCookies, null, 2)); 212 | console.log(`✅ Found ${mentions.length} total mentions`); 213 | return mentions; 214 | }} catch (error) {{ 215 | console.log(`❌ Error checking mentions: ${error.message}`); 216 | return []; 217 | }} 218 | }})(); 219 | """ 220 | 221 | try: 222 | result = _run_node_script(script) 223 | if isinstance(result, list): 224 | print(f"✅ Successfully fetched {len(result)} mentions") 225 | return result 226 | elif isinstance(result, dict) and 'mentions' in result: 227 | mentions = result['mentions'] 228 | print(f"✅ Successfully fetched {len(mentions)} mentions") 229 | return mentions 230 | else: 231 | print(f"❌ Unexpected response format: {result}") 232 | return [] 233 | except Exception as e: 234 | print(f"❌ Error parsing mentions response: {e}") 235 | return [] 236 | -------------------------------------------------------------------------------- /src/mcpagentai/tools/weather_agent.py: -------------------------------------------------------------------------------- 1 | import json 2 | import requests 3 | from typing import Sequence, Union 4 | 5 | from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource 6 | from mcpagentai.core.agent_base import MCPAgent 7 | from mcpagentai.defs import WeatherTools, CurrentWeatherResult, WeatherForecastResult 8 | 9 | 10 | # A simple mapping from Open-Meteo weathercode to textual description: 11 | WEATHER_CODE_MAP = { 12 | 0: "Clear sky", 13 | 1: "Mainly clear", 14 | 2: "Partly cloudy", 15 | 3: "Overcast", 16 | 45: "Fog", 17 | 48: "Depositing rime fog", 18 | 51: "Light drizzle", 19 | 53: "Moderate drizzle", 20 | 55: "Dense drizzle", 21 | 56: "Freezing drizzle", 22 | 57: "Freezing drizzle", 23 | 61: "Slight rain", 24 | 63: "Moderate rain", 25 | 65: "Heavy rain", 26 | 66: "Freezing rain", 27 | 67: "Freezing rain", 28 | 71: "Slight snowfall", 29 | 73: "Moderate snowfall", 30 | 75: "Heavy snowfall", 31 | 77: "Snow grains", 32 | 80: "Slight rain showers", 33 | 81: "Moderate rain showers", 34 | 82: "Violent rain showers", 35 | 85: "Slight snow showers", 36 | 86: "Heavy snow showers", 37 | 95: "Thunderstorm", 38 | 96: "Thunderstorm with hail", 39 | 99: "Thunderstorm with heavy hail" 40 | } 41 | 42 | 43 | class WeatherAgent(MCPAgent): 44 | """ 45 | Agent that handles weather functionality (current weather, forecast) 46 | using the free Open-Meteo API. 47 | Expects 'location' to be in 'lat,lon' format (e.g., '52.52,13.41'). 48 | """ 49 | 50 | def list_tools(self) -> list[Tool]: 51 | return [ 52 | Tool( 53 | name=WeatherTools.GET_CURRENT_WEATHER.value, 54 | description="Get current weather for a specific location (lat,lon).", 55 | inputSchema={ 56 | "type": "object", 57 | "properties": { 58 | "location": { 59 | "type": "string", 60 | "description": "Coordinates in 'lat,lon' format (e.g. '52.52,13.41')", 61 | }, 62 | }, 63 | "required": ["location"], 64 | }, 65 | ), 66 | Tool( 67 | name=WeatherTools.FORECAST.value, 68 | description="Get forecast for a specific location (lat,lon).", 69 | inputSchema={ 70 | "type": "object", 71 | "properties": { 72 | "location": { 73 | "type": "string", 74 | "description": "Coordinates in 'lat,lon' format (e.g. '52.52,13.41')", 75 | }, 76 | "days": { 77 | "type": "integer", 78 | "description": "Number of days to forecast (1-7 recommended for daily).", 79 | }, 80 | }, 81 | "required": ["location"], 82 | }, 83 | ), 84 | ] 85 | 86 | def call_tool( 87 | self, 88 | name: str, 89 | arguments: dict 90 | ) -> Sequence[Union[TextContent, ImageContent, EmbeddedResource]]: 91 | if name == WeatherTools.GET_CURRENT_WEATHER.value: 92 | return self._handle_get_current_weather(arguments) 93 | elif name == WeatherTools.FORECAST.value: 94 | return self._handle_forecast(arguments) 95 | else: 96 | raise ValueError(f"Unknown tool: {name}") 97 | 98 | def _handle_get_current_weather(self, arguments: dict) -> Sequence[TextContent]: 99 | location = arguments.get("location", "") 100 | result = self._get_current_weather(location) 101 | return [ 102 | TextContent(type="text", text=json.dumps(result.model_dump(), indent=2)) 103 | ] 104 | 105 | def _handle_forecast(self, arguments: dict) -> Sequence[TextContent]: 106 | location = arguments.get("location", "") 107 | days = arguments.get("days", 3) 108 | result = self._get_forecast(location, days) 109 | return [ 110 | TextContent(type="text", text=json.dumps(result.model_dump(), indent=2)) 111 | ] 112 | 113 | def _parse_lat_lon(self, location: str) -> tuple[float, float]: 114 | """ 115 | Expects a string like '52.52,13.41'. 116 | Returns (52.52, 13.41) as floats. 117 | """ 118 | try: 119 | lat_str, lon_str = location.split(",") 120 | return float(lat_str.strip()), float(lon_str.strip()) 121 | except Exception: 122 | raise ValueError( 123 | "Location must be in 'lat,lon' format (e.g. '52.52,13.41')." 124 | ) 125 | 126 | def _get_current_weather(self, location: str) -> CurrentWeatherResult: 127 | """ 128 | Calls the Open-Meteo API for current weather. 129 | """ 130 | lat, lon = self._parse_lat_lon(location) 131 | 132 | url = "https://api.open-meteo.com/v1/forecast" 133 | params = { 134 | "latitude": lat, 135 | "longitude": lon, 136 | "current_weather": "true", 137 | "timezone": "auto" # Let the API pick best timezone 138 | } 139 | 140 | resp = requests.get(url, params=params) 141 | data = resp.json() 142 | 143 | # Example structure: 144 | # { 145 | # "latitude": 52.52, 146 | # "longitude": 13.419998, 147 | # "generationtime_ms": 0.62, 148 | # "utc_offset_seconds": 7200, 149 | # "timezone": "Europe/Berlin", 150 | # "timezone_abbreviation": "CEST", 151 | # "elevation": 38.0, 152 | # "current_weather": { 153 | # "temperature": 16.4, 154 | # "windspeed": 2.6, 155 | # "winddirection": 316.0, 156 | # "weathercode": 0, 157 | # "time": "2025-01-07T12:00" 158 | # } 159 | # } 160 | if "current_weather" not in data: 161 | raise ValueError("No current weather data found for the given location.") 162 | 163 | cw = data["current_weather"] 164 | weathercode = cw.get("weathercode", 0) 165 | description = WEATHER_CODE_MAP.get(weathercode, "Unknown weather conditions") 166 | 167 | return CurrentWeatherResult( 168 | location=f"{lat},{lon}", 169 | temperature=cw.get("temperature", 0.0), 170 | description=description 171 | ) 172 | 173 | def _get_forecast(self, location: str, days: int) -> WeatherForecastResult: 174 | """ 175 | Calls the Open-Meteo API for daily forecast up to 7 (or more) days. 176 | """ 177 | lat, lon = self._parse_lat_lon(location) 178 | 179 | # We'll fetch daily weathercode, max temp, min temp 180 | # Open-Meteo by default can provide up to 7 or 14 days. 181 | # We'll just request up to 7 days to be safe (or you can request 16). 182 | if days < 1: 183 | days = 1 184 | elif days > 7: 185 | days = 7 # or 16 if you prefer 186 | 187 | url = "https://api.open-meteo.com/v1/forecast" 188 | params = { 189 | "latitude": lat, 190 | "longitude": lon, 191 | "daily": "weathercode,temperature_2m_max,temperature_2m_min", 192 | "timezone": "auto" 193 | } 194 | 195 | resp = requests.get(url, params=params) 196 | data = resp.json() 197 | 198 | if "daily" not in data: 199 | raise ValueError("No daily forecast data found for the given location.") 200 | 201 | daily_data = data["daily"] 202 | 203 | # daily_data example structure: 204 | # { 205 | # "time": ["2025-01-07", "2025-01-08", ...], 206 | # "weathercode": [0, 2, ...], 207 | # "temperature_2m_max": [17.0, 19.5, ...], 208 | # "temperature_2m_min": [8.1, 10.2, ...] 209 | # } 210 | time_list = daily_data.get("time", []) 211 | code_list = daily_data.get("weathercode", []) 212 | max_list = daily_data.get("temperature_2m_max", []) 213 | min_list = daily_data.get("temperature_2m_min", []) 214 | 215 | forecast_items = [] 216 | for i in range(min(days, len(time_list))): 217 | weathercode = code_list[i] if i < len(code_list) else 0 218 | desc = WEATHER_CODE_MAP.get(weathercode, "Unknown weather conditions") 219 | high = max_list[i] if i < len(max_list) else 0.0 220 | low = min_list[i] if i < len(min_list) else 0.0 221 | 222 | forecast_items.append({ 223 | "day": i + 1, 224 | "date": time_list[i] if i < len(time_list) else "Unknown", 225 | "description": desc, 226 | "high": high, 227 | "low": low 228 | }) 229 | 230 | return WeatherForecastResult( 231 | location=f"{lat},{lon}", 232 | forecast=forecast_items 233 | ) 234 | -------------------------------------------------------------------------------- /src/mcpagentai/tools/twitter/api_agent.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | import json # Ensure json is imported 4 | import tweepy 5 | 6 | from typing import Sequence, Union 7 | from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource 8 | from mcp.shared.exceptions import McpError 9 | from mcpagentai.core.agent_base import MCPAgent 10 | from mcpagentai.defs import TwitterTools, TwitterResult 11 | 12 | 13 | class TwitterAgent(MCPAgent): 14 | """ 15 | Agent that handles creating tweets and replying to tweets using the Twitter API v2 via Tweepy. 16 | """ 17 | 18 | def __init__(self): 19 | super().__init__() 20 | self.bearer_token = os.getenv("TWITTER_BEARER_TOKEN") 21 | self.api_key = os.getenv("TWITTER_API_KEY") 22 | self.api_key_secret = os.getenv("TWITTER_API_SECRET") 23 | self.access_token = os.getenv("TWITTER_ACCESS_TOKEN") 24 | self.access_token_secret = os.getenv("TWITTER_ACCESS_SECRET") 25 | 26 | if not self.bearer_token: 27 | self.logger.warning( 28 | "Bearer token is missing. Please set your TWITTER_BEARER_TOKEN environment variable." 29 | ) 30 | 31 | # Initialize Tweepy Client for API v2 32 | self._authenticate() 33 | 34 | def _authenticate(self) -> tweepy.Client: 35 | """ 36 | Authenticates with Twitter API using Tweepy's Client for API v2. 37 | 38 | Returns: 39 | tweepy.Client: Authenticated Tweepy Client instance. 40 | """ 41 | try: 42 | # Initialize Tweepy Client with OAuth 2.0 Bearer Token 43 | self.client = tweepy.Client( 44 | bearer_token=self.bearer_token, 45 | consumer_key=self.api_key, 46 | consumer_secret=self.api_key_secret, 47 | access_token=self.access_token, 48 | access_token_secret=self.access_token_secret, 49 | wait_on_rate_limit=True 50 | ) 51 | # Verify credentials 52 | user = self.client.get_user(username=self._get_username()) 53 | if user.data: 54 | self.logger.info("Successfully authenticated with Twitter API v2.") 55 | else: 56 | raise McpError("Authentication failed: Unable to fetch user data.") 57 | return 58 | except Exception as e: 59 | self.logger.error(f"Error during Twitter authentication: {e}") 60 | raise McpError(f"Twitter authentication failed: {e}") from e 61 | 62 | def _get_username(self) -> str: 63 | """ 64 | Retrieves the authenticated user's username. 65 | 66 | Returns: 67 | str: Twitter username. 68 | 69 | Raises: 70 | McpError: If unable to retrieve username. 71 | """ 72 | try: 73 | # Fetch the authenticated user 74 | response = self.client.get_me() 75 | if response.data and response.data.username: 76 | return response.data.username 77 | else: 78 | raise McpError("Unable to retrieve authenticated user's username.") 79 | except Exception as e: 80 | self.logger.error(f"Error fetching authenticated user's username: {e}") 81 | raise McpError(f"Failed to get username: {e}") from e 82 | 83 | def list_tools(self) -> list[Tool]: 84 | """ 85 | Return the list of tools for tweeting and replying. 86 | """ 87 | return [ 88 | Tool( 89 | name=TwitterTools.CREATE_TWEET.value, 90 | description="Create a new tweet on the authenticated account.", 91 | inputSchema={ 92 | "type": "object", 93 | "properties": { 94 | "message": { 95 | "type": "string", 96 | "description": "The text content of the new tweet" 97 | }, 98 | }, 99 | "required": ["message"] 100 | }, 101 | ), 102 | Tool( 103 | name=TwitterTools.REPLY_TWEET.value, 104 | description="Reply to an existing tweet given the tweet ID and reply text.", 105 | inputSchema={ 106 | "type": "object", 107 | "properties": { 108 | "tweet_id": { 109 | "type": "string", 110 | "description": "The ID of the tweet to reply to" 111 | }, 112 | "message": { 113 | "type": "string", 114 | "description": "The text content of the reply" 115 | }, 116 | }, 117 | "required": ["tweet_id", "message"] 118 | }, 119 | ), 120 | ] 121 | 122 | def call_tool( 123 | self, 124 | name: str, 125 | arguments: dict 126 | ) -> Sequence[Union[TextContent, ImageContent, EmbeddedResource]]: 127 | """ 128 | Dispatch calls to create a new Tweet or reply to an existing Tweet. 129 | """ 130 | self.logger.debug("TwitterAgent call_tool => name=%s, arguments=%s", name, arguments) 131 | if name == TwitterTools.CREATE_TWEET.value: 132 | return self._handle_create_tweet(arguments) 133 | elif name == TwitterTools.REPLY_TWEET.value: 134 | return self._handle_reply_tweet(arguments) 135 | else: 136 | raise ValueError(f"Unknown tool: {name}") 137 | 138 | def _handle_create_tweet(self, arguments: dict) -> Sequence[TextContent]: 139 | """ 140 | Creates a new Tweet on the user's timeline. 141 | """ 142 | message = arguments.get("message", "").strip() 143 | if not message: 144 | raise McpError("No tweet message provided") 145 | 146 | if not hasattr(self, 'client') or not self.client: 147 | raise McpError("Twitter client not initialized. Check your credentials.") 148 | 149 | try: 150 | # Create a new tweet using API v2 151 | response = self.client.create_tweet(text=message) 152 | if response.data: 153 | tweet_id = response.data['id'] 154 | tweet_url = f"https://twitter.com/{self._get_username()}/status/{tweet_id}" 155 | result = TwitterResult( 156 | success=True, 157 | message="Tweet posted successfully", 158 | tweet_url=tweet_url 159 | ) 160 | self.logger.info(f"Tweet created successfully: {tweet_url}") 161 | return [TextContent(type="text", text=result.model_dump_json(indent=2))] 162 | else: 163 | raise McpError("Failed to create tweet: No data returned.") 164 | except tweepy.TweepyException as e: 165 | error_msg = f"Error posting tweet: {e}" 166 | self.logger.error(error_msg) 167 | raise McpError(error_msg) from e 168 | except Exception as e: 169 | error_msg = f"Unexpected error posting tweet: {e}" 170 | self.logger.error(error_msg) 171 | raise McpError(error_msg) from e 172 | 173 | def _handle_reply_tweet(self, arguments: dict) -> Sequence[TextContent]: 174 | """ 175 | Replies to an existing Tweet by ID. 176 | """ 177 | tweet_id = arguments.get("tweet_id", "").strip() 178 | message = arguments.get("message", "").strip() 179 | if not tweet_id: 180 | raise McpError("No tweet_id provided") 181 | if not message: 182 | raise McpError("No reply message provided") 183 | 184 | if not hasattr(self, 'client') or not self.client: 185 | raise McpError("Twitter client not initialized. Check your credentials.") 186 | 187 | try: 188 | # Reply to a tweet using API v2 189 | response = self.client.create_tweet( 190 | text=message, 191 | in_reply_to_tweet_id=tweet_id 192 | ) 193 | self.logger.debug(f"Reply Tweet Response: {response}") 194 | 195 | if response.data: 196 | reply_id = response.data['id'] 197 | reply_url = f"https://twitter.com/{self._get_username()}/status/{reply_id}" 198 | result = TwitterResult( 199 | success=True, 200 | message="Replied to tweet successfully", 201 | tweet_url=reply_url 202 | ) 203 | self.logger.info(f"Replied to tweet successfully: {reply_url}") 204 | return [TextContent(type="text", text=result.model_dump_json(indent=2))] 205 | else: 206 | raise McpError("Failed to reply to tweet: No data returned.") 207 | except tweepy.TweepyException as e: 208 | error_msg = f"Error replying to tweet: {e}" 209 | self.logger.error(error_msg) 210 | raise McpError(error_msg) from e 211 | except KeyError as e: 212 | error_msg = f"Missing expected key in response data: {e}" 213 | self.logger.error(error_msg) 214 | raise McpError(error_msg) from e 215 | except Exception as e: 216 | error_msg = f"Unexpected error replying to tweet: {e}" 217 | self.logger.error(error_msg) 218 | raise McpError(error_msg) from e 219 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # MCPAgentAI 🚀 3 | 4 | [![PyPI](https://img.shields.io/pypi/v/mcpagentai.svg)](https://pypi.org/project/mcpagentai/) 5 | [![Python Versions](https://img.shields.io/pypi/pyversions/mcpagentai.svg)](https://pypi.org/project/mcpagentai/) 6 | [![License](https://img.shields.io/pypi/l/mcpagentai.svg)](https://github.com/mcpagents-ai/mcpagentai/blob/main/LICENSE) 7 | 8 | **MCPAgentAI** is a standardized **tool wrapping framework** for implementing and managing diverse tools in a unified way. It is designed to help developers quickly integrate and launch tool-based use cases. 9 | 10 | ### Key Features 11 | - 🔧 **Standardized Wrapping**: Provides an abstraction layer for building tools using the MCP protocol. 12 | - 🚀 **Flexible Use Cases**: Easily add or remove tools to fit your specific requirements. 13 | - ✨ **Out-of-the-Box Tools**: Includes pre-built tools for common scenarios: 14 | - 🐦 **Twitter Management**: Automate tweeting, replying, and managing Twitter interactions. 15 | - 💸 Crypto: Get the latest cryptocurrency prices. 16 | - 🤖 [ElizaOS](https://github.com/elizaos/eliza) Integration: Seamlessly connect and interact with ElizaOS for enhanced automation. 17 | - 🕑 Time utilities 18 | - ☁️ Weather information (API) 19 | - 📚 Dictionary lookups 20 | - 🧮 Calculator for mathematical expressions 21 | - 💵 Currency exchange (API) 22 | - 📈 Stocks Data: Access real-time and historical stock market information. 23 | - [WIP] 📰 News: Retrieve the latest news headlines. 24 | 25 | ### Tech Stack 🛠️ 26 | - **Python**: Core programming language 27 | - **[MCP](https://pypi.org/project/mcp/) Framework**: Communication protocol 28 | - **Docker**: Containerization 29 | 30 | #### 🤔 What is MCP? 31 | 32 | The **Model Context Protocol ([MCP](https://modelcontextprotocol.io/introduction))** is a cutting-edge standard for **context sharing and management** across AI models and systems. Think of it as the **language** AI agents use to interact seamlessly. 🧠✨ 33 | 34 | Here’s why **MCP** matters: 35 | 36 | - 🧩 **Standardization**: MCP defines how context can be shared across models, enabling **interoperability**. 37 | - ⚡ **Scalability**: It’s built to handle large-scale AI systems with high throughput. 38 | - 🔒 **Security**: Robust authentication and fine-grained access control. 39 | - 🌐 **Flexibility**: Works across diverse systems and AI architectures. 40 | 41 | ![mcp_architecture](https://imageio.forbes.com/specials-images/imageserve/674aaa6ac3007d55b62fc2bf/MCP-Architecture/960x0.png?height=559&width=711&fit=bounds) 42 | [source](https://www.forbes.com/sites/janakirammsv/2024/11/30/why-anthropics-model-context-protocol-is-a-big-step-in-the-evolution-of-ai-agents/) 43 | --- 44 | 45 | ## Installation 📦 46 | 47 | ### Install via PyPI 48 | ```bash 49 | pip install mcpagentai 50 | ``` 51 | 52 | --- 53 | 54 | ## Usage 💻 55 | 56 | ### Run Locally 57 | ```bash 58 | mcpagentai --local-timezone "America/New_York" 59 | ``` 60 | 61 | ### Run in Docker 62 | 1. **Build the Docker image:** 63 | `docker build -t mcpagentai .` 64 | 65 | 2. **Run the container:** 66 | `docker run -i --rm mcpagentai` 67 | 68 | --- 69 | 70 | ## Twitter Integration 🐦 71 | 72 | MCPAgentAI offers robust Twitter integration, allowing you to automate tweeting, replying, and managing Twitter interactions. This section provides detailed instructions on configuring and using the Twitter integration, both via Docker and `.env` + `scripts/run_agent.sh`. 73 | 74 | ### Docker Environment Variables for Twitter Integration 75 | 76 | When running MCPAgentAI within Docker, it's essential to configure environment variables for Twitter integration. These variables are divided into two categories: 77 | 78 | #### 1. Agent Node Client Credentials 79 | These credentials are used by the **Node.js client** within the agent for managing Twitter interactions. 80 | 81 | ```dockerfile 82 | ENV TWITTER_USERNAME= 83 | ENV TWITTER_PASSWORD= 84 | ENV TWITTER_EMAIL= 85 | ``` 86 | 87 | #### 2. Tweepy (Twitter API v2) Credentials 88 | These credentials are utilized by **Tweepy** for interacting with Twitter's API v2. 89 | 90 | ```dockerfile 91 | ENV TWITTER_API_KEY= 92 | ENV TWITTER_API_SECRET= 93 | ENV TWITTER_ACCESS_TOKEN= 94 | ENV TWITTER_ACCESS_SECRET= 95 | ENV TWITTER_CLIENT_ID= 96 | ENV TWITTER_CLIENT_SECRET= 97 | ENV TWITTER_BEARER_TOKEN= 98 | ``` 99 | 100 | ### Running MCPAgentAI with Docker 101 | 102 | 1. **Build the Docker image:** 103 | ```bash 104 | docker build -t mcpagentai . 105 | ``` 106 | 107 | 2. **Run the container:** 108 | ```bash 109 | docker run -i --rm mcpagentai 110 | ``` 111 | 112 | ### Running MCPAgentAI with `.env` + `scripts/run_agent.sh` 113 | 114 | #### Setting Up Environment Variables 115 | 116 | Create a `.env` file in the root directory of your project and add the following environment variables: 117 | 118 | ```dotenv 119 | ANTHROPIC_API_KEY=your_anthropic_api_key 120 | ELIZA_PATH=/path/to/eliza 121 | TWITTER_USERNAME=your_twitter_username 122 | TWITTER_EMAIL=your_twitter_email 123 | TWITTER_PASSWORD=your_twitter_password 124 | PERSONALITY_CONFIG=/path/to/personality_config.json 125 | RUN_AGENT=True 126 | 127 | # Tweepy (Twitter API v2) Credentials 128 | TWITTER_API_KEY=your_twitter_api_key 129 | TWITTER_API_SECRET=your_twitter_api_secret 130 | TWITTER_ACCESS_TOKEN=your_twitter_access_token 131 | TWITTER_ACCESS_SECRET=your_twitter_access_secret 132 | TWITTER_CLIENT_ID=your_twitter_client_id 133 | TWITTER_CLIENT_SECRET=your_twitter_client_secret 134 | TWITTER_BEARER_TOKEN=your_twitter_bearer_token 135 | ``` 136 | 137 | #### Running the Agent 138 | 139 | 1. **Make the script executable:** 140 | ```bash 141 | chmod +x scripts/run_agent.sh 142 | ``` 143 | 144 | 2. **Run the agent:** 145 | ```bash 146 | bash scripts/run_agent.sh 147 | ``` 148 | 149 | 150 | ### Summary 151 | You can configure MCPAgentAI to run with Twitter integration either using Docker or by setting up environment variables in a `.env` file and running the `scripts/run_agent.sh` script. 152 | 153 | This flexibility allows you to choose the method that best fits your deployment environment. 154 | 155 | --- 156 | 157 | ## ElizaOS Integration 🤖 158 | 159 | [MCPAgentAI](https://github.com/mcpagents-ai/mcpagentai) offers seamless integration with [ElizaOS](https://github.com/elizaos/eliza), providing enhanced automation capabilities through Eliza Agents. There are two primary ways to integrate Eliza Agents: 160 | 161 | ### **1. Directly Use Eliza Agents from mcpagentai** 162 | This approach allows you to use Eliza Agents without running the Eliza Framework in the background. It simplifies the setup by embedding Eliza functionality directly within MCPAgentAI. 163 | 164 | **Steps:** 165 | 166 | 1. **Configure MCPAgentAI to Use Eliza MCP Agent:** 167 | In your Python code, add Eliza MCP Agent to the `MultiToolAgent`: 168 | ```python 169 | from mcpagentai.core.multi_tool_agent import MultiToolAgent 170 | from mcpagentai.tools.eliza_mcp_agent import eliza_mcp_agent 171 | 172 | multi_tool_agent = MultiToolAgent([ 173 | # ... other agents 174 | eliza_mcp_agent 175 | ]) 176 | ``` 177 | 178 | **Advantages:** 179 | - **Simplified Setup:** No need to manage separate background processes. 180 | - **Easier Monitoring:** All functionalities are encapsulated within MCPAgentAI. 181 | - **Highlight Feature:** Emphasizes the flexibility of MCPAgentAI in integrating various tools seamlessly. 182 | 183 | 184 | ### **2. Run Eliza Framework from mcpagentai** 185 | This method involves running the Eliza Framework as a separate background process alongside MCPAgentAI. 186 | 187 | **Steps:** 188 | 189 | 1. **Start Eliza Framework:** 190 | `bash src/mcpagentai/tools/eliza/scripts/run.sh` 191 | 192 | 2. **Monitor Eliza Processes:** 193 | `bash src/mcpagentai/tools/eliza/scripts/monitor.sh` 194 | 195 | 3. **Configure MCPAgentAI to Use Eliza Agent:** 196 | In your Python code, add Eliza Agent to the `MultiToolAgent`: 197 | ```python 198 | from mcpagentai.core.multi_tool_agent import MultiToolAgent 199 | from mcpagentai.tools.eliza_agent import eliza_agent 200 | 201 | multi_tool_agent = MultiToolAgent([ 202 | # ... other agents 203 | eliza_agent 204 | ]) 205 | ``` 206 | --- 207 | 208 | ## Tutorial: Selecting Specific Tools 209 | 210 | You can configure MCPAgentAI to run only certain tools by modifying the agent configuration in your server or by updating the `server.py` file to only load desired agents. For example: 211 | 212 | ```python 213 | from mcpagentai.tools.time_agent import TimeAgent 214 | from mcpagentai.tools.weather_agent import WeatherAgent 215 | from mcpagentai.core.multi_tool_agent import MultiToolAgent 216 | 217 | multi_tool_agent = MultiToolAgent([ 218 | TimeAgent(), 219 | WeatherAgent() 220 | ]) 221 | This setup will only enable **Time** and **Weather** tools. 222 | ``` 223 | --- 224 | 225 | ## Integration Example: Claude Desktop Configuration 226 | 227 | You can integrate MCPAgentAI with Claude Desktop using the following configuration (`claude_desktop_config.json`), **note that** local ElizaOS repo is optional arg: 228 | ```json 229 | { 230 | "mcpServers": { 231 | "mcpagentai": { 232 | "command": "docker", 233 | "args": ["run", "-i", "-v", "/path/to/local/eliza:/app/eliza", "--rm", "mcpagentai"] 234 | } 235 | } 236 | } 237 | ``` 238 | --- 239 | 240 | ## Development 🛠️ 241 | 242 | 1. **Clone this repository:** 243 | ```bash 244 | git clone https://github.com/mcpagents-ai/mcpagentai.git 245 | cd mcpagentai 246 | ``` 247 | 248 | 2. **(Optional) Create a virtual environment:** 249 | ```bash 250 | python3 -m venv .venv 251 | source .venv/bin/activate 252 | ``` 253 | 254 | 3. **Install dependencies:** 255 | ```bash 256 | pip install -e . 257 | ``` 258 | 259 | 4. **Build the package:** 260 | ```bash 261 | python -m build 262 | ``` 263 | --- 264 | 265 | ## Contributing 🤝 266 | 267 | We welcome contributions! Please open an [issue](https://github.com/mcpagents-ai/mcpagentai/issues) or [pull request](https://github.com/mcpagents-ai/mcpagentai/pulls). 268 | 269 | --- 270 | 271 | **License**: MIT 272 | Enjoy! 🎉 273 | -------------------------------------------------------------------------------- /src/mcpagentai/tools/eliza/mcp_agent.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import logging 4 | 5 | from typing import Sequence, Union 6 | 7 | from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource, ErrorData 8 | from mcp.shared.exceptions import McpError 9 | 10 | from mcpagentai.defs import ( 11 | ElizaParserTools, 12 | ElizaGetCharacters, 13 | ElizaGetCharacterLore, 14 | ElizaGetCharacterBio, 15 | ) 16 | from mcpagentai.core.agent_base import MCPAgent 17 | 18 | 19 | class ElizaMCPAgent(MCPAgent): 20 | """ 21 | Handles local Eliza character JSON files for bios and lore and enables interaction with characters. 22 | """ 23 | 24 | def __init__(self): 25 | super().__init__() 26 | self.eliza_path = os.getenv("ELIZA_PATH") 27 | self.eliza_character_path = os.path.join(self.eliza_path, "characters") 28 | 29 | self.logger.info("ElizaMCPAgent initialized with character path: %s", self.eliza_character_path) 30 | 31 | def list_tools(self) -> list[Tool]: 32 | return [ 33 | Tool( 34 | name=ElizaParserTools.GET_CHARACTERS.value, 35 | description="Get list of Eliza character JSON files", 36 | inputSchema={"type": "object", "properties": {}} 37 | ), 38 | Tool( 39 | name=ElizaParserTools.GET_CHARACTER_BIO.value, 40 | description="Get bio of a specific Eliza character", 41 | inputSchema={ 42 | "type": "object", 43 | "properties": { 44 | "character_json_file_name": { 45 | "type": "string", 46 | "description": "Name of character JSON file" 47 | } 48 | }, 49 | "required": ["character_json_file_name"] 50 | } 51 | ), 52 | Tool( 53 | name=ElizaParserTools.GET_CHARACTER_LORE.value, 54 | description="Get lore of a specific Eliza character", 55 | inputSchema={ 56 | "type": "object", 57 | "properties": { 58 | "character_json_file_name": { 59 | "type": "string", 60 | "description": "Name of character JSON file" 61 | } 62 | }, 63 | "required": ["character_json_file_name"] 64 | } 65 | ), 66 | Tool( 67 | name=ElizaParserTools.GET_FULL_AGENT_INFO.value, 68 | description="Get full agent info, including bio and lore, for all characters", 69 | inputSchema={"type": "object", "properties": {}} 70 | ), 71 | Tool( 72 | name=ElizaParserTools.INTERACT_WITH_AGENT.value, 73 | description="Interact with an agent by building a prompt based on bio, lore, and previous answers", 74 | inputSchema={ 75 | "type": "object", 76 | "properties": { 77 | "character_json_file_name": { 78 | "type": "string", 79 | "description": "Name of character JSON file" 80 | }, 81 | "question": { 82 | "type": "string", 83 | "description": "The question or input for the character" 84 | }, 85 | "previous_answers": { 86 | "type": "array", 87 | "items": {"type": "string"}, 88 | "description": "List of previous answers from the character" 89 | } 90 | }, 91 | "required": ["character_json_file_name", "question"] 92 | } 93 | ), 94 | ] 95 | 96 | def call_tool( 97 | self, 98 | name: str, 99 | arguments: dict 100 | ) -> Sequence[Union[TextContent, ImageContent, EmbeddedResource]]: 101 | self.logger.debug("ElizaMCPAgent call_tool => name=%s, arguments=%s", name, arguments) 102 | 103 | if name == ElizaParserTools.GET_CHARACTERS.value: 104 | return self._handle_get_characters(arguments) 105 | elif name == ElizaParserTools.GET_CHARACTER_BIO.value: 106 | return self._handle_get_character_bio(arguments) 107 | elif name == ElizaParserTools.GET_CHARACTER_LORE.value: 108 | return self._handle_get_character_lore(arguments) 109 | elif name == ElizaParserTools.GET_FULL_AGENT_INFO.value: 110 | return self._handle_get_full_agent_info(arguments) 111 | elif name == ElizaParserTools.INTERACT_WITH_AGENT.value: 112 | return self._handle_interact_with_agent(arguments) 113 | else: 114 | raise ValueError(f"Unknown tool value: {name}") 115 | 116 | def _handle_get_characters(self, arguments: dict) -> Sequence[TextContent]: 117 | result = self._get_characters() 118 | return [TextContent(type="text", text=json.dumps(result.model_dump(), indent=2))] 119 | 120 | def _handle_get_character_bio(self, arguments: dict) -> Sequence[TextContent]: 121 | filename = arguments.get("character_json_file_name") 122 | if not filename: 123 | raise McpError(ErrorData(message="Character JSON file name not provided", code=-1)) 124 | result = self._get_character_bio(filename) 125 | return [TextContent(type="text", text=json.dumps(result.model_dump(), indent=2))] 126 | 127 | def _handle_get_character_lore(self, arguments: dict) -> Sequence[TextContent]: 128 | filename = arguments.get("character_json_file_name") 129 | if not filename: 130 | raise McpError(ErrorData(message="Character JSON file name not provided", code=-1)) 131 | result = self._get_character_lore(filename) 132 | return [TextContent(type="text", text=json.dumps(result.model_dump(), indent=2))] 133 | 134 | def _handle_get_full_agent_info(self, arguments: dict) -> Sequence[TextContent]: 135 | """ 136 | Combine bio and lore for all characters and return as full info. 137 | """ 138 | self.logger.info("Getting full agent info for all characters.") 139 | characters = self._get_characters().characters 140 | all_info = {} 141 | 142 | for character in characters: 143 | bio = self._get_character_bio(character).characters 144 | lore = self._get_character_lore(character).characters 145 | all_info[character] = {"bio": bio, "lore": lore} 146 | 147 | return [TextContent(type="text", text=json.dumps(all_info, indent=2))] 148 | 149 | def _handle_interact_with_agent(self, arguments: dict) -> Sequence[TextContent]: 150 | """ 151 | Build a prompt for interaction with an agent. 152 | """ 153 | filename = arguments.get("character_json_file_name") 154 | question = arguments.get("question") 155 | previous_answers = arguments.get("previous_answers", []) 156 | 157 | if not filename: 158 | raise McpError(ErrorData(message="Character JSON file name not provided", code=-1)) 159 | if not question: 160 | raise McpError(ErrorData(message="Question not provided", code=-1)) 161 | 162 | bio = self._get_character_bio(filename).characters 163 | lore = self._get_character_lore(filename).characters 164 | 165 | prompt = f"Character Bio: {bio}\nCharacter Lore: {lore}\nPrevious Answers: {previous_answers}\nQuestion: {question}\nAnswer:" 166 | self.logger.debug("Generated prompt for interaction: %s", prompt) 167 | 168 | # In this placeholder, we just return the generated prompt. 169 | # You could integrate an LLM call here to process the prompt and generate a response. 170 | return [TextContent(type="text", text=prompt)] 171 | 172 | def _get_characters(self) -> ElizaGetCharacters: 173 | self.logger.info("Listing character files in %s", self.eliza_character_path) 174 | 175 | # Initialize debug message 176 | debug_message = { 177 | "jsonrpc": "2.0", 178 | "id": 1, 179 | "method": "debug.log", 180 | "params": {}, 181 | } 182 | 183 | # Add provided path 184 | debug_message["params"]["provided_eliza_character_path"] = self.eliza_character_path 185 | 186 | # Check if /app exists 187 | # app_path = "/app" 188 | # if os.path.exists(app_path): 189 | # debug_message["params"]["app_structure"] = [] 190 | # for root, dirs, files in os.walk(app_path): 191 | # if "node_modules" in dirs: 192 | # dirs.remove("node_modules") # Exclude node_modules 193 | # debug_message["params"]["app_structure"].append({ 194 | # "root": root, 195 | # "dirs": dirs, 196 | # "files": files, 197 | # }) 198 | # else: 199 | # debug_message["params"]["app_exists"] = False 200 | 201 | # Path existence and directory checks 202 | path_exists = os.path.exists(self.eliza_character_path) 203 | debug_message["params"]["path_exists"] = path_exists 204 | 205 | is_directory = os.path.isdir(self.eliza_character_path) 206 | debug_message["params"]["is_directory"] = is_directory 207 | 208 | if not is_directory: 209 | debug_message["error"] = f"Characters directory does not exist: {self.eliza_character_path}" 210 | print(json.dumps(debug_message)) 211 | raise FileNotFoundError(debug_message["error"]) 212 | 213 | # List files in the directory 214 | try: 215 | character_files = os.listdir(self.eliza_character_path) 216 | debug_message["params"]["character_files"] = character_files 217 | except Exception as e: 218 | debug_message["error"] = f"Error listing files in directory: {str(e)}" 219 | print(json.dumps(debug_message)) 220 | raise 221 | 222 | # Print final debug message 223 | print(json.dumps(debug_message)) 224 | 225 | return ElizaGetCharacters(characters=character_files) 226 | 227 | def _get_character_bio(self, filename: str) -> ElizaGetCharacterBio: 228 | file_path = os.path.join(self.eliza_character_path, filename) 229 | self.logger.info("Reading character bio from %s", file_path) 230 | 231 | if not os.path.exists(file_path): 232 | raise FileNotFoundError(f"File doesn't exist: {file_path}") 233 | 234 | with open(file_path, "r") as fp: 235 | data = json.load(fp) 236 | 237 | # Safely handle bio field as list or string 238 | bio_data = data.get("bio", "") 239 | if isinstance(bio_data, list): 240 | # Convert list of strings into a single string 241 | bio_data = " ".join(bio_data) 242 | 243 | return ElizaGetCharacterBio(characters=bio_data) 244 | 245 | def _get_character_lore(self, filename: str) -> ElizaGetCharacterLore: 246 | file_path = os.path.join(self.eliza_character_path, filename) 247 | self.logger.info("Reading character lore from %s", file_path) 248 | 249 | if not os.path.exists(file_path): 250 | raise FileNotFoundError(f"File doesn't exist: {file_path}") 251 | 252 | with open(file_path, "r") as fp: 253 | data = json.load(fp) 254 | 255 | # Safely handle lore field as list or string 256 | lore_data = data.get("lore", "") 257 | if isinstance(lore_data, list): 258 | # Convert list of strings into a single string 259 | lore_data = " ".join(lore_data) 260 | 261 | return ElizaGetCharacterLore(characters=lore_data) 262 | 263 | -------------------------------------------------------------------------------- /src/mcpagentai/tools/time_agent.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import datetime, timedelta 3 | from zoneinfo import ZoneInfo 4 | from typing import Sequence, Union 5 | 6 | from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource 7 | from mcp.shared.exceptions import McpError 8 | 9 | from mcpagentai.core.agent_base import MCPAgent 10 | from mcpagentai.defs import TimeTools, TimeResult, TimeConversionResult 11 | 12 | 13 | class TimeAgent(MCPAgent): 14 | """ 15 | Agent that handles time-related functionality (current time, time conversions). 16 | """ 17 | 18 | def __init__(self, local_timezone: str | None = None): 19 | super().__init__() 20 | self._local_timezone = local_timezone or self._autodetect_local_timezone() 21 | 22 | def list_tools(self) -> list[Tool]: 23 | return [ 24 | Tool( 25 | name=TimeTools.GET_CURRENT_TIME.value, 26 | description="Get current time in a specific timezone", 27 | inputSchema={ 28 | "type": "object", 29 | "properties": { 30 | "timezone": { 31 | "type": "string", 32 | "description": ( 33 | f"IANA timezone name (e.g. 'America/New_York'). " 34 | f"Use '{self._local_timezone}' if not provided." 35 | ), 36 | } 37 | }, 38 | "required": ["timezone"], 39 | }, 40 | ), 41 | Tool( 42 | name=TimeTools.CONVERT_TIME.value, 43 | description="Convert time between timezones", 44 | inputSchema={ 45 | "type": "object", 46 | "properties": { 47 | "source_timezone": { 48 | "type": "string", 49 | "description": ( 50 | f"Source IANA timezone name (e.g. 'America/New_York'). " 51 | f"Use '{self._local_timezone}' if not provided." 52 | ), 53 | }, 54 | "time": { 55 | "type": "string", 56 | "description": "Time to convert in 24-hour format (HH:MM)", 57 | }, 58 | "target_timezone": { 59 | "type": "string", 60 | "description": ( 61 | f"Target IANA timezone name (e.g. 'Asia/Tokyo'). " 62 | f"Use '{self._local_timezone}' if not provided." 63 | ), 64 | }, 65 | }, 66 | "required": ["source_timezone", "time", "target_timezone"], 67 | }, 68 | ), 69 | ] 70 | 71 | def call_tool( 72 | self, 73 | name: str, 74 | arguments: dict 75 | ) -> Sequence[Union[TextContent, ImageContent, EmbeddedResource]]: 76 | if name == TimeTools.GET_CURRENT_TIME.value: 77 | return self._handle_get_current_time(arguments) 78 | elif name == TimeTools.CONVERT_TIME.value: 79 | return self._handle_convert_time(arguments) 80 | else: 81 | raise ValueError(f"Unknown tool: {name}") 82 | 83 | def _handle_get_current_time(self, arguments: dict) -> Sequence[TextContent]: 84 | timezone_name = arguments.get("timezone") or self._local_timezone 85 | result_model = self._get_current_time(timezone_name) 86 | return [ 87 | TextContent(type="text", text=json.dumps(result_model.model_dump(), indent=2)) 88 | ] 89 | 90 | def _handle_convert_time(self, arguments: dict) -> Sequence[TextContent]: 91 | source_tz = arguments.get("source_timezone") or self._local_timezone 92 | time_str = arguments.get("time") 93 | target_tz = arguments.get("target_timezone") or self._local_timezone 94 | 95 | if not time_str: 96 | raise ValueError("Time string must be provided.") 97 | 98 | result_model = self._convert_time(source_tz, time_str, target_tz) 99 | return [ 100 | TextContent(type="text", text=json.dumps(result_model.model_dump(), indent=2)) 101 | ] 102 | 103 | def _autodetect_local_timezone(self) -> str: 104 | tzinfo = datetime.now().astimezone().tzinfo 105 | if tzinfo is not None: 106 | return str(tzinfo) 107 | raise McpError("Could not determine local timezone - tzinfo is None") 108 | 109 | def _get_current_time(self, timezone_name: str) -> TimeResult: 110 | timezone = self._get_zoneinfo(timezone_name) 111 | current_time = datetime.now(timezone) 112 | return TimeResult( 113 | timezone=timezone_name, 114 | datetime=current_time.isoformat(timespec="seconds"), 115 | is_dst=bool(current_time.dst()), 116 | ) 117 | 118 | def _convert_time(self, source_tz: str, time_str: str, target_tz: str) -> TimeConversionResult: 119 | source_timezone = self._get_zoneinfo(source_tz) 120 | target_timezone = self._get_zoneinfo(target_tz) 121 | 122 | # parse HH:MM format 123 | try: 124 | hh_mm = datetime.strptime(time_str, "%H:%M").time() 125 | except ValueError: 126 | raise ValueError("Invalid time format. Expected HH:MM in 24-hour format.") 127 | 128 | # use today's date for demonstration 129 | now = datetime.now(source_timezone) 130 | source_time = datetime( 131 | now.year, now.month, now.day, 132 | hh_mm.hour, hh_mm.minute, tzinfo=source_timezone 133 | ) 134 | target_time = source_time.astimezone(target_timezone) 135 | 136 | source_offset = source_time.utcoffset() or timedelta() 137 | target_offset = target_time.utcoffset() or timedelta() 138 | hours_diff = (target_offset - source_offset).total_seconds() / 3600 139 | 140 | if hours_diff.is_integer(): 141 | time_diff_str = f"{hours_diff:+.1f}h" 142 | else: 143 | # handle e.g. UTC+5:45 with fractional offsets 144 | time_diff_str = f"{hours_diff:+.2f}".rstrip("0").rstrip(".") + "h" 145 | 146 | return TimeConversionResult( 147 | source=TimeResult( 148 | timezone=source_tz, 149 | datetime=source_time.isoformat(timespec="seconds"), 150 | is_dst=bool(source_time.dst()), 151 | ), 152 | target=TimeResult( 153 | timezone=target_tz, 154 | datetime=target_time.isoformat(timespec="seconds"), 155 | is_dst=bool(target_time.dst()), 156 | ), 157 | time_difference=time_diff_str 158 | ) 159 | 160 | def _get_zoneinfo(self, timezone_name: str) -> ZoneInfo: 161 | try: 162 | return ZoneInfo(timezone_name) 163 | except Exception as e: 164 | raise McpError(f"Invalid timezone: {str(e)}") from e 165 | import json 166 | from datetime import datetime, timedelta 167 | from zoneinfo import ZoneInfo 168 | from typing import Sequence, Union 169 | 170 | from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource 171 | from mcp.shared.exceptions import McpError 172 | 173 | from mcpagentai.core.agent_base import MCPAgent 174 | from mcpagentai.defs import TimeTools, TimeResult, TimeConversionResult 175 | 176 | 177 | class TimeAgent(MCPAgent): 178 | """ 179 | Agent that handles time-related functionality (current time, time conversions). 180 | """ 181 | 182 | def __init__(self, local_timezone: str | None = None): 183 | super().__init__() 184 | self._local_timezone = local_timezone or self._autodetect_local_timezone() 185 | 186 | def list_tools(self) -> list[Tool]: 187 | return [ 188 | Tool( 189 | name=TimeTools.GET_CURRENT_TIME.value, 190 | description="Get current time in a specific timezone", 191 | inputSchema={ 192 | "type": "object", 193 | "properties": { 194 | "timezone": { 195 | "type": "string", 196 | "description": ( 197 | f"IANA timezone name (e.g. 'America/New_York'). " 198 | f"Use '{self._local_timezone}' if not provided." 199 | ), 200 | } 201 | }, 202 | "required": ["timezone"], 203 | }, 204 | ), 205 | Tool( 206 | name=TimeTools.CONVERT_TIME.value, 207 | description="Convert time between timezones", 208 | inputSchema={ 209 | "type": "object", 210 | "properties": { 211 | "source_timezone": { 212 | "type": "string", 213 | "description": ( 214 | f"Source IANA timezone name (e.g. 'America/New_York'). " 215 | f"Use '{self._local_timezone}' if not provided." 216 | ), 217 | }, 218 | "time": { 219 | "type": "string", 220 | "description": "Time to convert in 24-hour format (HH:MM)", 221 | }, 222 | "target_timezone": { 223 | "type": "string", 224 | "description": ( 225 | f"Target IANA timezone name (e.g. 'Asia/Tokyo'). " 226 | f"Use '{self._local_timezone}' if not provided." 227 | ), 228 | }, 229 | }, 230 | "required": ["source_timezone", "time", "target_timezone"], 231 | }, 232 | ), 233 | ] 234 | 235 | def call_tool( 236 | self, 237 | name: str, 238 | arguments: dict 239 | ) -> Sequence[Union[TextContent, ImageContent, EmbeddedResource]]: 240 | if name == TimeTools.GET_CURRENT_TIME.value: 241 | return self._handle_get_current_time(arguments) 242 | elif name == TimeTools.CONVERT_TIME.value: 243 | return self._handle_convert_time(arguments) 244 | else: 245 | raise ValueError(f"Unknown tool: {name}") 246 | 247 | def _handle_get_current_time(self, arguments: dict) -> Sequence[TextContent]: 248 | timezone_name = arguments.get("timezone") or self._local_timezone 249 | result_model = self._get_current_time(timezone_name) 250 | return [ 251 | TextContent(type="text", text=json.dumps(result_model.model_dump(), indent=2)) 252 | ] 253 | 254 | def _handle_convert_time(self, arguments: dict) -> Sequence[TextContent]: 255 | source_tz = arguments.get("source_timezone") or self._local_timezone 256 | time_str = arguments.get("time") 257 | target_tz = arguments.get("target_timezone") or self._local_timezone 258 | 259 | if not time_str: 260 | raise ValueError("Time string must be provided.") 261 | 262 | result_model = self._convert_time(source_tz, time_str, target_tz) 263 | return [ 264 | TextContent(type="text", text=json.dumps(result_model.model_dump(), indent=2)) 265 | ] 266 | 267 | def _autodetect_local_timezone(self) -> str: 268 | tzinfo = datetime.now().astimezone().tzinfo 269 | if tzinfo is not None: 270 | return str(tzinfo) 271 | raise McpError("Could not determine local timezone - tzinfo is None") 272 | 273 | def _get_current_time(self, timezone_name: str) -> TimeResult: 274 | timezone = self._get_zoneinfo(timezone_name) 275 | current_time = datetime.now(timezone) 276 | return TimeResult( 277 | timezone=timezone_name, 278 | datetime=current_time.isoformat(timespec="seconds"), 279 | is_dst=bool(current_time.dst()), 280 | ) 281 | 282 | def _convert_time(self, source_tz: str, time_str: str, target_tz: str) -> TimeConversionResult: 283 | source_timezone = self._get_zoneinfo(source_tz) 284 | target_timezone = self._get_zoneinfo(target_tz) 285 | 286 | # parse HH:MM format 287 | try: 288 | hh_mm = datetime.strptime(time_str, "%H:%M").time() 289 | except ValueError: 290 | raise ValueError("Invalid time format. Expected HH:MM in 24-hour format.") 291 | 292 | # use today's date for demonstration 293 | now = datetime.now(source_timezone) 294 | source_time = datetime( 295 | now.year, now.month, now.day, 296 | hh_mm.hour, hh_mm.minute, tzinfo=source_timezone 297 | ) 298 | target_time = source_time.astimezone(target_timezone) 299 | 300 | source_offset = source_time.utcoffset() or timedelta() 301 | target_offset = target_time.utcoffset() or timedelta() 302 | hours_diff = (target_offset - source_offset).total_seconds() / 3600 303 | 304 | if hours_diff.is_integer(): 305 | time_diff_str = f"{hours_diff:+.1f}h" 306 | else: 307 | # handle e.g. UTC+5:45 with fractional offsets 308 | time_diff_str = f"{hours_diff:+.2f}".rstrip("0").rstrip(".") + "h" 309 | 310 | return TimeConversionResult( 311 | source=TimeResult( 312 | timezone=source_tz, 313 | datetime=source_time.isoformat(timespec="seconds"), 314 | is_dst=bool(source_time.dst()), 315 | ), 316 | target=TimeResult( 317 | timezone=target_tz, 318 | datetime=target_time.isoformat(timespec="seconds"), 319 | is_dst=bool(target_time.dst()), 320 | ), 321 | time_difference=time_diff_str 322 | ) 323 | 324 | def _get_zoneinfo(self, timezone_name: str) -> ZoneInfo: 325 | try: 326 | return ZoneInfo(timezone_name) 327 | except Exception as e: 328 | raise McpError(f"Invalid timezone: {str(e)}") from e 329 | -------------------------------------------------------------------------------- /src/mcpagentai/tools/twitter/agent.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import time 4 | import random 5 | import anthropic 6 | from typing import Sequence, Union, Dict, Any, Optional 7 | from pathlib import Path 8 | import asyncio 9 | from dotenv import load_dotenv 10 | 11 | from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource 12 | from mcp.shared.exceptions import McpError 13 | 14 | from mcpagentai.core.agent_base import MCPAgent 15 | from mcpagentai.defs import TwitterTools, StockTools, WeatherTools 16 | from mcpagentai.tools.stock_agent import StockAgent 17 | from mcpagentai.tools.weather_agent import WeatherAgent 18 | from mcpagentai.tools.time_agent import TimeAgent 19 | from . import agent_client_wrapper 20 | 21 | # Load environment variables from .env file 22 | env_path = Path(__file__).parent.parent.parent.parent.parent / '.env' 23 | load_dotenv(env_path) 24 | 25 | 26 | class TwitterAgent(MCPAgent): 27 | """ 28 | AI-powered Twitter agent that uses Claude to generate tweets and replies 29 | """ 30 | 31 | def __init__(self): 32 | super().__init__() 33 | self.last_tweet_time = 0 34 | self.last_reply_time = 0 35 | self.last_action_time = 0 36 | 37 | # Initialize query handlers 38 | self.query_handlers = {} 39 | self._load_query_handlers() 40 | 41 | # Load personality config 42 | personality_file = os.getenv("PERSONALITY_CONFIG", "tech_expert.json") 43 | self.personality = self.load_personality(personality_file) 44 | 45 | # Initialize Anthropic client 46 | self.client = anthropic.Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY")) 47 | 48 | # Setup storage paths - use project root for cookies.json 49 | self.project_root = Path.cwd() 50 | self.store_dir = self.project_root / 'store' 51 | self.store_dir.mkdir(exist_ok=True) 52 | self.replied_file = self.store_dir / 'replied_tweets.json' 53 | self.cookies_file = self.project_root / 'cookies.json' # cookies.json is in root 54 | 55 | # Load replied tweets from file 56 | self.replied_to = self.load_replied_tweets() 57 | 58 | # Start tweet and reply monitoring loops 59 | if os.getenv("RUN_AGENT", "false").lower() == "true": 60 | asyncio.create_task(self._tweet_loop()) 61 | asyncio.create_task(self._reply_loop()) 62 | 63 | def _load_query_handlers(self): 64 | """Load all available query handlers""" 65 | # Import handlers here to avoid circular imports 66 | from mcpagentai.tools.twitter.handlers.weather_handler import WeatherQueryHandler 67 | from mcpagentai.tools.twitter.handlers.stock_handler import StockQueryHandler 68 | from mcpagentai.tools.twitter.handlers.time_handler import TimeQueryHandler 69 | from mcpagentai.tools.twitter.handlers.crypto_handler import CryptoQueryHandler 70 | from mcpagentai.tools.twitter.handlers.currency_handler import CurrencyQueryHandler 71 | 72 | # Initialize handlers 73 | handlers = [ 74 | WeatherQueryHandler(), 75 | StockQueryHandler(), 76 | TimeQueryHandler(), 77 | CryptoQueryHandler(), 78 | CurrencyQueryHandler() 79 | ] 80 | 81 | # Register handlers 82 | for handler in handlers: 83 | self.query_handlers[handler.query_type] = handler 84 | self.logger.info(f"Registered query handler for: {handler.query_type}") 85 | 86 | def _get_available_handlers_info(self) -> str: 87 | """Get information about available handlers for Claude's prompt""" 88 | info = [] 89 | for handler in self.query_handlers.values(): 90 | examples_str = "\n".join([f"- {q}" for q in handler.examples.keys()]) 91 | info.append(f""" 92 | {handler.query_type.upper()}: 93 | Parameters: {handler.available_params} 94 | Example queries: 95 | {examples_str} 96 | """) 97 | return "\n".join(info) 98 | 99 | def load_personality(self, personality_file: str) -> dict: 100 | """Load personality configuration""" 101 | character_path = os.path.join( 102 | os.path.dirname(__file__), 103 | '../eliza/characters', 104 | personality_file 105 | ) 106 | with open(character_path, 'r') as f: 107 | return json.load(f) 108 | 109 | def load_replied_tweets(self) -> set: 110 | """Load the set of tweets we've replied to""" 111 | try: 112 | if self.replied_file.exists(): 113 | # Check if file is empty 114 | if self.replied_file.stat().st_size == 0: 115 | # Initialize with empty array if file is empty 116 | with open(self.replied_file, 'w') as f: 117 | json.dump([], f) 118 | return set() 119 | 120 | # Try to load existing data 121 | with open(self.replied_file) as f: 122 | return set(json.load(f)) 123 | else: 124 | # Create file with empty array if it doesn't exist 125 | self.replied_file.parent.mkdir(parents=True, exist_ok=True) 126 | with open(self.replied_file, 'w') as f: 127 | json.dump([], f) 128 | return set() 129 | 130 | except json.JSONDecodeError as e: 131 | self.logger.warning(f"Invalid JSON in replied tweets file: {e}") 132 | # Backup the corrupted file and create new empty one 133 | backup_file = self.replied_file.with_suffix('.json.bak') 134 | self.replied_file.rename(backup_file) 135 | with open(self.replied_file, 'w') as f: 136 | json.dump([], f) 137 | return set() 138 | 139 | except Exception as e: 140 | self.logger.warning(f"Could not load replied tweets: {e}") 141 | # Initialize file with empty array for any other error 142 | with open(self.replied_file, 'w') as f: 143 | json.dump([], f) 144 | return set() # Return empty set for any failure case 145 | 146 | def save_replied_tweets(self): 147 | """Save the set of replied tweets to file""" 148 | with open(self.replied_file, 'w') as f: 149 | json.dump(list(self.replied_to), f) 150 | 151 | async def generate_tweet(self) -> str: 152 | """Generate a tweet using the personality config and query handlers""" 153 | context = {} 154 | 155 | # Randomly decide which data to include (0-2 types of data) 156 | data_types = ["time", "stock", "crypto", "weather"] 157 | num_data = random.randint(0, 4) # Sometimes tweet with no data, max 2 types 158 | if num_data == 0: 159 | context["other"] = random.choice([ 160 | "tweet type 1", 161 | ]) 162 | else: 163 | selected_data = random.sample(data_types, num_data) 164 | 165 | # Get time if selected (no API, should always work) 166 | if "time" in selected_data: 167 | try: 168 | if "time" in self.query_handlers: 169 | time_data = self.query_handlers["time"].handle_query({"city": "nyc"}) 170 | if time_data: 171 | context["time"] = time_data 172 | except Exception as e: 173 | self.logger.warning(f"Error getting time data: {e}") 174 | 175 | # Get stock info if selected 176 | if "stock" in selected_data: 177 | try: 178 | if "stock" in self.query_handlers: 179 | stock_data = self.query_handlers["stock"].handle_query({"ticker": "IBM"}) 180 | if stock_data and "API Limit" not in stock_data and "Error" not in stock_data: 181 | context["stocks"] = stock_data 182 | self.logger.debug(f"Got stock price: {context['stocks']}") 183 | except Exception as e: 184 | self.logger.warning(f"Error getting stock data: {e}") 185 | 186 | # Get crypto info if selected 187 | if "crypto" in selected_data: 188 | try: 189 | if "crypto" in self.query_handlers: 190 | crypto_data = self.query_handlers["crypto"].handle_query({"symbol": "BTC"}) 191 | if crypto_data and "Error" not in crypto_data: 192 | context["crypto"] = crypto_data 193 | self.logger.debug(f"Got crypto price: {context['crypto']}") 194 | except Exception as e: 195 | self.logger.warning(f"Error getting crypto data: {e}") 196 | 197 | # Get weather info if selected 198 | if "weather" in selected_data: 199 | try: 200 | if "weather" in self.query_handlers: 201 | weather_data = self.query_handlers["weather"].handle_query({"city": "sf"}) 202 | if weather_data and "Error" not in weather_data: 203 | context["weather"] = weather_data 204 | self.logger.debug(f"Got weather: {context['weather']}") 205 | except Exception as e: 206 | self.logger.warning(f"Error getting weather data: {e}") 207 | 208 | # Add context to prompt 209 | system_prompt = f"""You are {self.personality['name']}, {self.personality['lore']}. 210 | Bio: {' '.join(self.personality['bio'])} 211 | Style: {' '.join(self.personality['style']['post'])} 212 | 213 | IMPORTANT: You must respond with valid JSON in exactly this format, nothing else: 214 | {{"text": "your tweet here"}} 215 | 216 | Current context: 217 | {f"- Time: {context.get('time')}" if 'time' in context else ""} 218 | {f"- Stocks: {context.get('stocks')}" if 'stocks' in context else ""} 219 | {f"- Crypto: {context.get('crypto')}" if 'crypto' in context else ""} 220 | {f"- Weather: {context.get('weather')}" if 'weather' in context else ""} 221 | 222 | Keep tweets under 280 characters and match your personality. 223 | Only mention data that is available in the context. 224 | If no data is available, just tweet something casual and fun that matches your personality. 225 | Vary your tweets - don't always focus on the same topics.""" 226 | 227 | try: 228 | response = self.client.messages.create( 229 | model="claude-3-sonnet-20240229", 230 | max_tokens=150, 231 | system=system_prompt, 232 | messages=[{ 233 | "role": "user", 234 | "content": "Generate a tweet incorporating the context. Respond ONLY with the JSON object." 235 | }] 236 | ) 237 | 238 | # Parse the JSON response 239 | tweet_data = json.loads(response.content[0].text.strip()) 240 | return tweet_data["text"] 241 | 242 | except Exception as e: 243 | self.logger.error(f"Error generating tweet: {e}") 244 | return None 245 | 246 | async def generate_reply(self, tweet_context: Dict[str, Any]) -> Optional[str]: 247 | try: 248 | # First try to identify if this is a data query 249 | analysis_response = self.client.messages.create( 250 | model="claude-3-sonnet-20240229", 251 | max_tokens=150, 252 | system="""You are a query analyzer. Extract data requirements from tweets. 253 | If the tweet is a general question or conversation, respond with {"type": "conversation"}. 254 | Otherwise, respond with data queries in this format: 255 | { 256 | "queries": [ 257 | { 258 | "type": "weather|stock|time|crypto|currency", 259 | "params": { 260 | // Parameters specific to the query type 261 | } 262 | } 263 | ] 264 | } 265 | 266 | Query type guidelines: 267 | - Use "currency" for fiat currency exchange rates (USD, EUR, CAD, etc.) 268 | - Use "crypto" only for cryptocurrency queries (BTC, ETH, etc.) 269 | - Use "weather" for weather queries with city in params 270 | - Use "stock" for stock market queries with ticker symbol 271 | - Use "time" for timezone/time queries with city""", 272 | messages=[{ 273 | "role": "user", 274 | "content": f"Analyze this tweet for specific data queries or identify if it's conversational: {tweet_context['text']}" 275 | }] 276 | ) 277 | 278 | try: 279 | query_data = json.loads(analysis_response.content[0].text) 280 | context = {} 281 | 282 | # Handle conversational tweets differently 283 | if query_data.get("type") == "conversation": 284 | # Generate a conversational response that directly addresses the tweet 285 | reply_response = self.client.messages.create( 286 | model="claude-3-sonnet-20240229", 287 | max_tokens=150, 288 | system=f"""You are {self.personality['name']}, {self.personality['personality']}. 289 | Bio: {' '.join(self.personality['bio'])} 290 | Style: {' '.join(self.personality['style']['chat'])} 291 | 292 | IMPORTANT: You must respond with valid JSON in exactly this format, nothing else: 293 | {{"text": "your reply here"}} 294 | 295 | The user's tweet: "{tweet_context['text']}" 296 | 297 | Guidelines for conversational replies: 298 | 1. Always acknowledge or reference what the user said 299 | 2. If they asked a question, make sure to answer it directly 300 | 3. Stay in character and maintain your personality 301 | 4. Keep replies under 280 characters 302 | 5. Be engaging and friendly 303 | 6. If you're unsure about something, it's okay to say so""", 304 | messages=[{ 305 | "role": "user", 306 | "content": "Generate a conversational reply that directly addresses the tweet. Respond ONLY with the JSON object." 307 | }] 308 | ) 309 | else: 310 | # Handle data-focused queries as before 311 | for query in query_data.get("queries", []): 312 | if query["type"] in self.query_handlers: 313 | handler = self.query_handlers[query["type"]] 314 | response = handler.handle_query(query["params"]) 315 | 316 | if isinstance(response, list): 317 | response = response[0] if response else None 318 | 319 | if response and not response.startswith("Error"): 320 | context[query["type"]] = response 321 | 322 | # Generate the reply using the data context 323 | reply_response = self.client.messages.create( 324 | model="claude-3-sonnet-20240229", 325 | max_tokens=150, 326 | system=f"""You are {self.personality['name']}, {self.personality['personality']}. 327 | Bio: {' '.join(self.personality['bio'])} 328 | Style: {' '.join(self.personality['style']['chat'])} 329 | 330 | IMPORTANT: You must respond with valid JSON in exactly this format, nothing else: 331 | {{"text": "your reply here"}} 332 | 333 | The user's tweet: "{tweet_context['text']}" 334 | Available data: {json.dumps(context)} 335 | 336 | Guidelines for data-focused replies: 337 | 1. Always include the specific data they asked for 338 | 2. If there is currency exchange rate data, include the actual numbers 339 | 3. Keep replies under 280 characters 340 | 4. Stay in character while providing the information 341 | 5. Only mention data that is available in the context""", 342 | messages=[{ 343 | "role": "user", 344 | "content": "Generate a reply incorporating the context and directly addressing their query. Respond ONLY with the JSON object." 345 | }] 346 | ) 347 | 348 | if reply_response and reply_response.content: 349 | reply_data = json.loads(reply_response.content[0].text.strip()) 350 | reply_text = reply_data["text"] 351 | # Remove any @ mentions from the reply 352 | reply_text = ' '.join(word for word in reply_text.split() if not word.startswith('@')) 353 | return reply_text 354 | 355 | except json.JSONDecodeError as e: 356 | self.logger.error(f"Error parsing response: {e}") 357 | return None 358 | 359 | except Exception as e: 360 | self.logger.error(f"Error processing reply: {e}") 361 | return None 362 | 363 | except Exception as e: 364 | self.logger.error(f"Error generating reply: {e}") 365 | return None 366 | 367 | def list_tools(self) -> list[Tool]: 368 | """List available Twitter tools""" 369 | return [ 370 | Tool( 371 | name=TwitterTools.CREATE_TWEET.value, 372 | description="Create a new tweet", 373 | inputSchema={ 374 | "type": "object", 375 | "properties": {} 376 | } 377 | ), 378 | Tool( 379 | name=TwitterTools.REPLY_TWEET.value, 380 | description="Reply to a tweet", 381 | inputSchema={ 382 | "type": "object", 383 | "properties": { 384 | "username": {"type": "string"}, 385 | "tweet_text": {"type": "string"}, 386 | "tweet_url": {"type": "string"} 387 | }, 388 | "required": ["username", "tweet_text", "tweet_url"] 389 | } 390 | ) 391 | ] 392 | 393 | async def call_tool( 394 | self, 395 | name: str, 396 | arguments: dict 397 | ) -> Sequence[Union[TextContent, ImageContent, EmbeddedResource]]: 398 | """Handle tool calls""" 399 | if name == TwitterTools.CREATE_TWEET.value: 400 | return await self._handle_create_tweet(arguments) 401 | elif name == TwitterTools.REPLY_TWEET.value: 402 | return await self._handle_reply_tweet(arguments) 403 | else: 404 | raise ValueError(f"Unknown tool: {name}") 405 | 406 | async def _handle_create_tweet(self, arguments: dict) -> Sequence[TextContent]: 407 | """Handle tweet creation""" 408 | if not self.should_tweet(): 409 | return [TextContent(type="text", text="Too soon to tweet again")] 410 | 411 | tweet_text = await self.generate_tweet() 412 | if not tweet_text: 413 | self.logger.error("Failed to generate tweet") 414 | raise McpError("Failed to generate tweet") 415 | 416 | result = agent_client_wrapper.send_tweet(tweet_text) 417 | self.last_tweet_time = time.time() 418 | 419 | return [TextContent( 420 | type="text", 421 | text=json.dumps({"generated_tweet": tweet_text, "result": result}, indent=2) 422 | )] 423 | 424 | async def _handle_reply_tweet(self, arguments: dict) -> Sequence[TextContent]: 425 | now = time.time() 426 | if now - self.last_reply_time < random.randint(60, 120): # 1-2 minutes 427 | return [TextContent(type="text", text="Too soon to reply again")] 428 | 429 | tweet_context = { 430 | "username": arguments["username"], 431 | "text": arguments["tweet_text"], 432 | "url": arguments["tweet_url"] 433 | } 434 | 435 | reply_text = await self.generate_reply(tweet_context) 436 | if not reply_text: 437 | raise McpError("Failed to generate reply") 438 | 439 | result = agent_client_wrapper.reply_tweet(reply_text, tweet_context["url"]) 440 | self.last_reply_time = now 441 | 442 | # Add to replied set and save 443 | tweet_id = agent_client_wrapper.extract_tweet_id(tweet_context["url"]) 444 | self.replied_to.add(tweet_id) 445 | self.save_replied_tweets() 446 | 447 | return [TextContent( 448 | type="text", 449 | text=json.dumps({"generated_reply": reply_text, "result": result}, indent=2) 450 | )] 451 | 452 | async def _tweet_loop(self): 453 | """Periodically generate and post tweets following original bot's schedule""" 454 | while True: 455 | try: 456 | now = time.time() 457 | # Random interval between 20-40 minutes (1200-2400 seconds) 458 | if now - self.last_tweet_time > random.randint(1200, 2400): 459 | self.logger.info("Generating scheduled tweet...") 460 | await self._handle_create_tweet({}) 461 | except Exception as e: 462 | self.logger.error(f"Error in tweet loop: {e}") 463 | 464 | # Check every 5 minutes 465 | await asyncio.sleep(300) 466 | 467 | def should_tweet(self) -> bool: 468 | """Check if enough time has passed since last tweet (20-40 minutes)""" 469 | now = time.time() 470 | if now - self.last_tweet_time < 1200: # 20 minutes minimum 471 | self.logger.debug('Too soon to tweet again') 472 | return False 473 | self.logger.debug('Ready to tweet') 474 | return True 475 | 476 | def should_reply(self, tweet_id: str) -> bool: 477 | """Check if we should reply to this tweet""" 478 | if tweet_id in self.replied_to: 479 | self.logger.debug(f"Already replied to tweet {tweet_id}") 480 | return False 481 | now = time.time() 482 | if now - self.last_reply_time < 300: # 5 minutes minimum between replies 483 | self.logger.debug(f"Too soon to reply to tweet {tweet_id}") 484 | return False 485 | self.logger.debug(f"Ready to reply to tweet {tweet_id}") 486 | return True 487 | 488 | async def can_perform_action(self) -> bool: 489 | """Global rate limiting check for any tweet action""" 490 | now = time.time() 491 | if now - self.last_action_time < 60: # Global 1 minute minimum between ANY actions 492 | self.logger.debug("Rate limit cooldown active") 493 | return False 494 | self.logger.debug("Ready to perform action") 495 | return True 496 | 497 | async def _reply_loop(self): 498 | """Periodically check for mentions and reply to them""" 499 | retry_count = 0 500 | max_retries = 3 501 | base_wait = 60 # Base wait time in seconds 502 | 503 | while True: 504 | try: 505 | # Check rate limit first 506 | if not await self.can_perform_action(): 507 | self.logger.info("⏳ Rate limit cooldown active, waiting 60 seconds...") 508 | await asyncio.sleep(60) 509 | continue 510 | 511 | # Calculate wait time between reply checks 512 | time_since_reply = time.time() - self.last_reply_time 513 | wait_time = random.randint(60, 120) # Random 1-2 minute interval 514 | 515 | if time_since_reply < wait_time: 516 | remaining_time = int(wait_time - time_since_reply) 517 | self.logger.info(f"💤 Next mention check in {remaining_time} seconds...") 518 | await asyncio.sleep(remaining_time) 519 | continue 520 | 521 | self.logger.info("\n🔍 Checking for new mentions...") 522 | 523 | # Define the Node.js script for checking mentions 524 | script = """ 525 | const { Scraper } = require('agent-twitter-client'); 526 | const { Cookie } = require('tough-cookie'); 527 | 528 | async function checkMentions() { 529 | try { 530 | const scraper = new Scraper(); 531 | const cookiesData = require('./cookies.json'); 532 | 533 | const cookies = cookiesData.map(cookieData => { 534 | const cookie = new Cookie({ 535 | key: cookieData.key, 536 | value: cookieData.value, 537 | domain: cookieData.domain, 538 | path: cookieData.path, 539 | secure: cookieData.secure, 540 | httpOnly: cookieData.httpOnly 541 | }); 542 | return cookie.toString(); 543 | }); 544 | 545 | await scraper.setCookies(cookies); 546 | 547 | const mentions = []; 548 | for await (const mention of scraper.searchTweets( 549 | `@${process.env.TWITTER_USERNAME}`, 550 | 100, // Increased to get more historical tweets 551 | 1 // SearchMode.Latest 552 | )) { 553 | // Skip if it's our own tweet 554 | if (mention.username === process.env.TWITTER_USERNAME) continue; 555 | 556 | mentions.push({ 557 | id: mention.id, 558 | username: mention.username, 559 | text: mention.text 560 | }); 561 | } 562 | console.log(JSON.stringify(mentions)); 563 | } catch (error) { 564 | console.log(JSON.stringify({ 565 | success: false, 566 | error: error.message 567 | })); 568 | } 569 | } 570 | 571 | checkMentions(); 572 | """ 573 | 574 | try: 575 | mentions = await self.run_node_script(script) 576 | 577 | if isinstance(mentions, str): 578 | try: 579 | mentions = json.loads(mentions) 580 | except json.JSONDecodeError: 581 | raise Exception(f"Failed to parse mentions response: {mentions}") 582 | 583 | # Handle scraper error response 584 | if isinstance(mentions, dict) and not mentions.get('success'): 585 | error_msg = mentions.get('error', 'Unknown error') 586 | raise Exception(f"Scraper error: {error_msg}") 587 | 588 | if not isinstance(mentions, list): 589 | raise Exception(f"Invalid mentions format: {mentions}") 590 | 591 | # Reset retry count on successful request 592 | retry_count = 0 593 | self.logger.info(f"✨ Found {len(mentions)} total mentions") 594 | 595 | for mention in mentions: 596 | if not await self.can_perform_action(): # Check rate limit for each reply 597 | self.logger.info("⏳ Rate limit reached, pausing replies...") 598 | break 599 | 600 | # Skip if we've already replied 601 | if mention['id'] in self.replied_to: 602 | self.logger.info(f"⏭️ Already replied to tweet {mention['id']}") 603 | continue 604 | 605 | self.logger.info(f"\n📝 Processing mention from @{mention['username']}: {mention['text']}") 606 | 607 | # Generate AI response without mentioning the user 608 | reply = await self.generate_reply({ 609 | 'username': mention['username'], 610 | 'text': mention['text'], 611 | 'url': f"https://twitter.com/{mention['username']}/status/{mention['id']}" 612 | }) 613 | 614 | if reply: 615 | # Remove any @ mentions from the reply 616 | reply = ' '.join(word for word in reply.split() if not word.startswith('@')) 617 | self.logger.info(f"✍️ Generated reply: {reply}") 618 | 619 | # Update times BEFORE sending to prevent parallel tweets 620 | now = time.time() 621 | self.last_reply_time = now 622 | self.last_action_time = now 623 | 624 | self.logger.info(f"🚀 Sending reply to tweet {mention['id']}...") 625 | success = await self.send_tweet(reply, mention['id']) 626 | if success: 627 | self.replied_to.add(mention['id']) 628 | self.logger.info(f"✅ Successfully replied to tweet {mention['id']}") 629 | self.logger.info(f"Replied to tweet {mention['id']}: {reply}") 630 | self.save_replied_tweets() 631 | else: 632 | self.logger.info(f"❌ Failed to send reply to tweet {mention['id']}") 633 | # Reset timers if tweet failed 634 | self.last_reply_time = now - 120 635 | self.last_action_time = now - 60 636 | else: 637 | self.logger.info(f"❌ Failed to generate reply for tweet {mention['id']}") 638 | 639 | except Exception as e: 640 | retry_count += 1 641 | wait_time = min(base_wait * (2 ** retry_count), 3600) # Max 1 hour wait 642 | self.logger.info(f"❌ Error checking mentions (attempt {retry_count}/{max_retries}): {str(e)}") 643 | self.logger.info(f"⏳ Waiting {wait_time} seconds before retry...") 644 | self.logger.error(f"Error checking mentions: {str(e)}") 645 | await asyncio.sleep(wait_time) 646 | 647 | if retry_count >= max_retries: 648 | self.logger.info("🔄 Max retries reached, resetting retry count...") 649 | retry_count = 0 650 | # Wait a longer time before starting fresh 651 | await asyncio.sleep(1800) # 30 minutes 652 | continue 653 | 654 | except Exception as e: 655 | self.logger.info(f"❌ Error in reply loop: {str(e)}") 656 | self.logger.error(f"Error in reply loop: {str(e)}") 657 | await asyncio.sleep(300) # Wait 5 minutes on unexpected errors 658 | 659 | self.logger.info("\n💤 Waiting 60 seconds before next mention check...") 660 | await asyncio.sleep(60) 661 | 662 | async def run_node_script(self, script): 663 | """Run a Node.js script and return the result""" 664 | # Create a temporary JS file in the store directory 665 | temp_script = self.store_dir.parent / 'temp_script.js' 666 | temp_script.write_text(script) 667 | 668 | process = await asyncio.create_subprocess_exec( 669 | 'node', str(temp_script), 670 | stdout=asyncio.subprocess.PIPE, 671 | stderr=asyncio.subprocess.PIPE, 672 | env={**os.environ}, # Pass current environment variables to Node 673 | cwd=str(self.store_dir.parent) # Run from the project root 674 | ) 675 | stdout, stderr = await process.communicate() 676 | 677 | # Clean up 678 | temp_script.unlink() 679 | 680 | if process.returncode != 0: 681 | raise Exception(f"Node.js error: {stderr.decode()}") 682 | 683 | try: 684 | return json.loads(stdout.decode()) 685 | except: 686 | return stdout.decode() 687 | 688 | async def send_tweet(self, text, reply_to=None): 689 | """Send a tweet with optional reply_to""" 690 | self.logger.info(f"\n📤 Preparing to send tweet{' as reply' if reply_to else ''}") 691 | self.logger.info(f"📝 Tweet text: {text}") 692 | 693 | # Properly escape the tweet text for JavaScript 694 | escaped_text = ( 695 | text.replace('\\', '\\\\') 696 | .replace("'", "\\'") 697 | .replace('"', '\\"') 698 | .replace('\n', '\\n') 699 | .replace('\r', '\\r') 700 | .replace('\t', '\\t') 701 | ) 702 | 703 | script = f""" 704 | const {{ Scraper }} = require('agent-twitter-client'); 705 | const {{ Cookie }} = require('tough-cookie'); 706 | 707 | async function tweet() {{ 708 | try {{ 709 | const scraper = new Scraper(); 710 | const cookiesData = require('./cookies.json'); 711 | 712 | const cookies = cookiesData.map(cookieData => {{ 713 | const cookie = new Cookie({{ 714 | key: cookieData.key, 715 | value: cookieData.value, 716 | domain: cookieData.domain, 717 | path: cookieData.path, 718 | secure: cookieData.secure, 719 | httpOnly: cookieData.httpOnly 720 | }}); 721 | return cookie.toString(); 722 | }}); 723 | 724 | await scraper.setCookies(cookies); 725 | 726 | const response = await scraper.sendTweet('{escaped_text}'{", '" + reply_to + "'" if reply_to else ''}); 727 | console.log(JSON.stringify({{ 728 | success: true, 729 | response: response, 730 | text: '{escaped_text}', 731 | timestamp: new Date().toISOString() 732 | }})); 733 | }} catch (error) {{ 734 | console.log(JSON.stringify({{ 735 | success: false, 736 | error: error.message 737 | }})); 738 | }} 739 | }} 740 | 741 | tweet(); 742 | """ 743 | 744 | result = await self.run_node_script(script) 745 | self.logger.info(f"📨 Tweet API response: {result}") 746 | 747 | if isinstance(result, str): 748 | try: 749 | result = json.loads(result) 750 | except: 751 | self.logger.info("❌ Failed to parse API response") 752 | self.logger.error(f"Failed to parse result: {result}") 753 | return False 754 | 755 | if not result.get('success'): 756 | self.logger.info(f"❌ Failed to send tweet: {result.get('error')}") 757 | self.logger.error(f"Failed to send tweet: {result.get('error')}") 758 | return False 759 | 760 | self.logger.info("✅ Tweet sent successfully!") 761 | self.logger.info(f"Successfully sent tweet: {text}") 762 | return True -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | requires-python = ">=3.12" 3 | 4 | [[package]] 5 | name = "annotated-types" 6 | version = "0.7.0" 7 | source = { registry = "https://pypi.org/simple" } 8 | sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } 9 | wheels = [ 10 | { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, 11 | ] 12 | 13 | [[package]] 14 | name = "anyio" 15 | version = "4.8.0" 16 | source = { registry = "https://pypi.org/simple" } 17 | dependencies = [ 18 | { name = "idna" }, 19 | { name = "sniffio" }, 20 | { name = "typing-extensions", marker = "python_full_version < '3.13'" }, 21 | ] 22 | sdist = { url = "https://files.pythonhosted.org/packages/a3/73/199a98fc2dae33535d6b8e8e6ec01f8c1d76c9adb096c6b7d64823038cde/anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a", size = 181126 } 23 | wheels = [ 24 | { url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041 }, 25 | ] 26 | 27 | [[package]] 28 | name = "certifi" 29 | version = "2024.12.14" 30 | source = { registry = "https://pypi.org/simple" } 31 | sdist = { url = "https://files.pythonhosted.org/packages/0f/bd/1d41ee578ce09523c81a15426705dd20969f5abf006d1afe8aeff0dd776a/certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db", size = 166010 } 32 | wheels = [ 33 | { url = "https://files.pythonhosted.org/packages/a5/32/8f6669fc4798494966bf446c8c4a162e0b5d893dff088afddf76414f70e1/certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56", size = 164927 }, 34 | ] 35 | 36 | [[package]] 37 | name = "charset-normalizer" 38 | version = "3.4.1" 39 | source = { registry = "https://pypi.org/simple" } 40 | sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 } 41 | wheels = [ 42 | { url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105 }, 43 | { url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404 }, 44 | { url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423 }, 45 | { url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184 }, 46 | { url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268 }, 47 | { url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601 }, 48 | { url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098 }, 49 | { url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520 }, 50 | { url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852 }, 51 | { url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488 }, 52 | { url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192 }, 53 | { url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550 }, 54 | { url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785 }, 55 | { url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 }, 56 | { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 }, 57 | { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 }, 58 | { url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966 }, 59 | { url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992 }, 60 | { url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162 }, 61 | { url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972 }, 62 | { url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095 }, 63 | { url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668 }, 64 | { url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073 }, 65 | { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732 }, 66 | { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391 }, 67 | { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702 }, 68 | { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 }, 69 | ] 70 | 71 | [[package]] 72 | name = "click" 73 | version = "8.1.8" 74 | source = { registry = "https://pypi.org/simple" } 75 | dependencies = [ 76 | { name = "colorama", marker = "sys_platform == 'win32'" }, 77 | ] 78 | sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } 79 | wheels = [ 80 | { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, 81 | ] 82 | 83 | [[package]] 84 | name = "colorama" 85 | version = "0.4.6" 86 | source = { registry = "https://pypi.org/simple" } 87 | sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } 88 | wheels = [ 89 | { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, 90 | ] 91 | 92 | [[package]] 93 | name = "h11" 94 | version = "0.14.0" 95 | source = { registry = "https://pypi.org/simple" } 96 | sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } 97 | wheels = [ 98 | { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, 99 | ] 100 | 101 | [[package]] 102 | name = "httpcore" 103 | version = "1.0.7" 104 | source = { registry = "https://pypi.org/simple" } 105 | dependencies = [ 106 | { name = "certifi" }, 107 | { name = "h11" }, 108 | ] 109 | sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 } 110 | wheels = [ 111 | { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 }, 112 | ] 113 | 114 | [[package]] 115 | name = "httpx" 116 | version = "0.28.1" 117 | source = { registry = "https://pypi.org/simple" } 118 | dependencies = [ 119 | { name = "anyio" }, 120 | { name = "certifi" }, 121 | { name = "httpcore" }, 122 | { name = "idna" }, 123 | ] 124 | sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } 125 | wheels = [ 126 | { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, 127 | ] 128 | 129 | [[package]] 130 | name = "httpx-sse" 131 | version = "0.4.0" 132 | source = { registry = "https://pypi.org/simple" } 133 | sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624 } 134 | wheels = [ 135 | { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819 }, 136 | ] 137 | 138 | [[package]] 139 | name = "idna" 140 | version = "3.10" 141 | source = { registry = "https://pypi.org/simple" } 142 | sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } 143 | wheels = [ 144 | { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, 145 | ] 146 | 147 | [[package]] 148 | name = "iniconfig" 149 | version = "2.0.0" 150 | source = { registry = "https://pypi.org/simple" } 151 | sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } 152 | wheels = [ 153 | { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, 154 | ] 155 | 156 | [[package]] 157 | name = "mcp" 158 | version = "1.2.0" 159 | source = { registry = "https://pypi.org/simple" } 160 | dependencies = [ 161 | { name = "anyio" }, 162 | { name = "httpx" }, 163 | { name = "httpx-sse" }, 164 | { name = "pydantic" }, 165 | { name = "pydantic-settings" }, 166 | { name = "sse-starlette" }, 167 | { name = "starlette" }, 168 | { name = "uvicorn" }, 169 | ] 170 | sdist = { url = "https://files.pythonhosted.org/packages/ab/a5/b08dc846ebedae9f17ced878e6975826e90e448cd4592f532f6a88a925a7/mcp-1.2.0.tar.gz", hash = "sha256:2b06c7ece98d6ea9e6379caa38d74b432385c338fb530cb82e2c70ea7add94f5", size = 102973 } 171 | wheels = [ 172 | { url = "https://files.pythonhosted.org/packages/af/84/fca78f19ac8ce6c53ba416247c71baa53a9e791e98d3c81edbc20a77d6d1/mcp-1.2.0-py3-none-any.whl", hash = "sha256:1d0e77d8c14955a5aea1f5aa1f444c8e531c09355c829b20e42f7a142bc0755f", size = 66468 }, 173 | ] 174 | 175 | [[package]] 176 | name = "mcpagentai" 177 | version = "0.2.0" 178 | source = { editable = "." } 179 | dependencies = [ 180 | { name = "mcp" }, 181 | { name = "pydantic" }, 182 | { name = "requests" }, 183 | { name = "tweepy" }, 184 | ] 185 | 186 | [package.dev-dependencies] 187 | dev = [ 188 | { name = "pytest" }, 189 | { name = "ruff" }, 190 | ] 191 | 192 | [package.metadata] 193 | requires-dist = [ 194 | { name = "mcp" }, 195 | { name = "pydantic" }, 196 | { name = "requests" }, 197 | { name = "tweepy" }, 198 | ] 199 | 200 | [package.metadata.requires-dev] 201 | dev = [ 202 | { name = "pytest", specifier = ">=7.0" }, 203 | { name = "ruff", specifier = ">=0.8.1" }, 204 | ] 205 | 206 | [[package]] 207 | name = "oauthlib" 208 | version = "3.2.2" 209 | source = { registry = "https://pypi.org/simple" } 210 | sdist = { url = "https://files.pythonhosted.org/packages/6d/fa/fbf4001037904031639e6bfbfc02badfc7e12f137a8afa254df6c4c8a670/oauthlib-3.2.2.tar.gz", hash = "sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918", size = 177352 } 211 | wheels = [ 212 | { url = "https://files.pythonhosted.org/packages/7e/80/cab10959dc1faead58dc8384a781dfbf93cb4d33d50988f7a69f1b7c9bbe/oauthlib-3.2.2-py3-none-any.whl", hash = "sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca", size = 151688 }, 213 | ] 214 | 215 | [[package]] 216 | name = "packaging" 217 | version = "24.2" 218 | source = { registry = "https://pypi.org/simple" } 219 | sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } 220 | wheels = [ 221 | { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, 222 | ] 223 | 224 | [[package]] 225 | name = "pluggy" 226 | version = "1.5.0" 227 | source = { registry = "https://pypi.org/simple" } 228 | sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } 229 | wheels = [ 230 | { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, 231 | ] 232 | 233 | [[package]] 234 | name = "pydantic" 235 | version = "2.10.4" 236 | source = { registry = "https://pypi.org/simple" } 237 | dependencies = [ 238 | { name = "annotated-types" }, 239 | { name = "pydantic-core" }, 240 | { name = "typing-extensions" }, 241 | ] 242 | sdist = { url = "https://files.pythonhosted.org/packages/70/7e/fb60e6fee04d0ef8f15e4e01ff187a196fa976eb0f0ab524af4599e5754c/pydantic-2.10.4.tar.gz", hash = "sha256:82f12e9723da6de4fe2ba888b5971157b3be7ad914267dea8f05f82b28254f06", size = 762094 } 243 | wheels = [ 244 | { url = "https://files.pythonhosted.org/packages/f3/26/3e1bbe954fde7ee22a6e7d31582c642aad9e84ffe4b5fb61e63b87cd326f/pydantic-2.10.4-py3-none-any.whl", hash = "sha256:597e135ea68be3a37552fb524bc7d0d66dcf93d395acd93a00682f1efcb8ee3d", size = 431765 }, 245 | ] 246 | 247 | [[package]] 248 | name = "pydantic-core" 249 | version = "2.27.2" 250 | source = { registry = "https://pypi.org/simple" } 251 | dependencies = [ 252 | { name = "typing-extensions" }, 253 | ] 254 | sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443 } 255 | wheels = [ 256 | { url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127 }, 257 | { url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340 }, 258 | { url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900 }, 259 | { url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177 }, 260 | { url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046 }, 261 | { url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386 }, 262 | { url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060 }, 263 | { url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870 }, 264 | { url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822 }, 265 | { url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364 }, 266 | { url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303 }, 267 | { url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064 }, 268 | { url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046 }, 269 | { url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092 }, 270 | { url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709 }, 271 | { url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273 }, 272 | { url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027 }, 273 | { url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888 }, 274 | { url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738 }, 275 | { url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138 }, 276 | { url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025 }, 277 | { url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633 }, 278 | { url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404 }, 279 | { url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130 }, 280 | { url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946 }, 281 | { url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387 }, 282 | { url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453 }, 283 | { url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186 }, 284 | ] 285 | 286 | [[package]] 287 | name = "pydantic-settings" 288 | version = "2.7.1" 289 | source = { registry = "https://pypi.org/simple" } 290 | dependencies = [ 291 | { name = "pydantic" }, 292 | { name = "python-dotenv" }, 293 | ] 294 | sdist = { url = "https://files.pythonhosted.org/packages/73/7b/c58a586cd7d9ac66d2ee4ba60ca2d241fa837c02bca9bea80a9a8c3d22a9/pydantic_settings-2.7.1.tar.gz", hash = "sha256:10c9caad35e64bfb3c2fbf70a078c0e25cc92499782e5200747f942a065dec93", size = 79920 } 295 | wheels = [ 296 | { url = "https://files.pythonhosted.org/packages/b4/46/93416fdae86d40879714f72956ac14df9c7b76f7d41a4d68aa9f71a0028b/pydantic_settings-2.7.1-py3-none-any.whl", hash = "sha256:590be9e6e24d06db33a4262829edef682500ef008565a969c73d39d5f8bfb3fd", size = 29718 }, 297 | ] 298 | 299 | [[package]] 300 | name = "pytest" 301 | version = "8.3.4" 302 | source = { registry = "https://pypi.org/simple" } 303 | dependencies = [ 304 | { name = "colorama", marker = "sys_platform == 'win32'" }, 305 | { name = "iniconfig" }, 306 | { name = "packaging" }, 307 | { name = "pluggy" }, 308 | ] 309 | sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919 } 310 | wheels = [ 311 | { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083 }, 312 | ] 313 | 314 | [[package]] 315 | name = "python-dotenv" 316 | version = "1.0.1" 317 | source = { registry = "https://pypi.org/simple" } 318 | sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 } 319 | wheels = [ 320 | { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 }, 321 | ] 322 | 323 | [[package]] 324 | name = "requests" 325 | version = "2.32.3" 326 | source = { registry = "https://pypi.org/simple" } 327 | dependencies = [ 328 | { name = "certifi" }, 329 | { name = "charset-normalizer" }, 330 | { name = "idna" }, 331 | { name = "urllib3" }, 332 | ] 333 | sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } 334 | wheels = [ 335 | { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, 336 | ] 337 | 338 | [[package]] 339 | name = "requests-oauthlib" 340 | version = "1.3.1" 341 | source = { registry = "https://pypi.org/simple" } 342 | dependencies = [ 343 | { name = "oauthlib" }, 344 | { name = "requests" }, 345 | ] 346 | sdist = { url = "https://files.pythonhosted.org/packages/95/52/531ef197b426646f26b53815a7d2a67cb7a331ef098bb276db26a68ac49f/requests-oauthlib-1.3.1.tar.gz", hash = "sha256:75beac4a47881eeb94d5ea5d6ad31ef88856affe2332b9aafb52c6452ccf0d7a", size = 52027 } 347 | wheels = [ 348 | { url = "https://files.pythonhosted.org/packages/6f/bb/5deac77a9af870143c684ab46a7934038a53eb4aa975bc0687ed6ca2c610/requests_oauthlib-1.3.1-py2.py3-none-any.whl", hash = "sha256:2577c501a2fb8d05a304c09d090d6e47c306fef15809d102b327cf8364bddab5", size = 23892 }, 349 | ] 350 | 351 | [[package]] 352 | name = "ruff" 353 | version = "0.8.6" 354 | source = { registry = "https://pypi.org/simple" } 355 | sdist = { url = "https://files.pythonhosted.org/packages/da/00/089db7890ea3be5709e3ece6e46408d6f1e876026ec3fd081ee585fef209/ruff-0.8.6.tar.gz", hash = "sha256:dcad24b81b62650b0eb8814f576fc65cfee8674772a6e24c9b747911801eeaa5", size = 3473116 } 356 | wheels = [ 357 | { url = "https://files.pythonhosted.org/packages/d7/28/aa07903694637c2fa394a9f4fe93cf861ad8b09f1282fa650ef07ff9fe97/ruff-0.8.6-py3-none-linux_armv6l.whl", hash = "sha256:defed167955d42c68b407e8f2e6f56ba52520e790aba4ca707a9c88619e580e3", size = 10628735 }, 358 | { url = "https://files.pythonhosted.org/packages/2b/43/827bb1448f1fcb0fb42e9c6edf8fb067ca8244923bf0ddf12b7bf949065c/ruff-0.8.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:54799ca3d67ae5e0b7a7ac234baa657a9c1784b48ec954a094da7c206e0365b1", size = 10386758 }, 359 | { url = "https://files.pythonhosted.org/packages/df/93/fc852a81c3cd315b14676db3b8327d2bb2d7508649ad60bfdb966d60738d/ruff-0.8.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e88b8f6d901477c41559ba540beeb5a671e14cd29ebd5683903572f4b40a9807", size = 10007808 }, 360 | { url = "https://files.pythonhosted.org/packages/94/e9/e0ed4af1794335fb280c4fac180f2bf40f6a3b859cae93a5a3ada27325ae/ruff-0.8.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0509e8da430228236a18a677fcdb0c1f102dd26d5520f71f79b094963322ed25", size = 10861031 }, 361 | { url = "https://files.pythonhosted.org/packages/82/68/da0db02f5ecb2ce912c2bef2aa9fcb8915c31e9bc363969cfaaddbc4c1c2/ruff-0.8.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:91a7ddb221779871cf226100e677b5ea38c2d54e9e2c8ed847450ebbdf99b32d", size = 10388246 }, 362 | { url = "https://files.pythonhosted.org/packages/ac/1d/b85383db181639019b50eb277c2ee48f9f5168f4f7c287376f2b6e2a6dc2/ruff-0.8.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:248b1fb3f739d01d528cc50b35ee9c4812aa58cc5935998e776bf8ed5b251e75", size = 11424693 }, 363 | { url = "https://files.pythonhosted.org/packages/ac/b7/30bc78a37648d31bfc7ba7105b108cb9091cd925f249aa533038ebc5a96f/ruff-0.8.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:bc3c083c50390cf69e7e1b5a5a7303898966be973664ec0c4a4acea82c1d4315", size = 12141921 }, 364 | { url = "https://files.pythonhosted.org/packages/60/b3/ee0a14cf6a1fbd6965b601c88d5625d250b97caf0534181e151504498f86/ruff-0.8.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:52d587092ab8df308635762386f45f4638badb0866355b2b86760f6d3c076188", size = 11692419 }, 365 | { url = "https://files.pythonhosted.org/packages/ef/d6/c597062b2931ba3e3861e80bd2b147ca12b3370afc3889af46f29209037f/ruff-0.8.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:61323159cf21bc3897674e5adb27cd9e7700bab6b84de40d7be28c3d46dc67cf", size = 12981648 }, 366 | { url = "https://files.pythonhosted.org/packages/68/84/21f578c2a4144917985f1f4011171aeff94ab18dfa5303ac632da2f9af36/ruff-0.8.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ae4478b1471fc0c44ed52a6fb787e641a2ac58b1c1f91763bafbc2faddc5117", size = 11251801 }, 367 | { url = "https://files.pythonhosted.org/packages/6c/aa/1ac02537c8edeb13e0955b5db86b5c050a1dcba54f6d49ab567decaa59c1/ruff-0.8.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0c000a471d519b3e6cfc9c6680025d923b4ca140ce3e4612d1a2ef58e11f11fe", size = 10849857 }, 368 | { url = "https://files.pythonhosted.org/packages/eb/00/020cb222252d833956cb3b07e0e40c9d4b984fbb2dc3923075c8f944497d/ruff-0.8.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:9257aa841e9e8d9b727423086f0fa9a86b6b420fbf4bf9e1465d1250ce8e4d8d", size = 10470852 }, 369 | { url = "https://files.pythonhosted.org/packages/00/56/e6d6578202a0141cd52299fe5acb38b2d873565f4670c7a5373b637cf58d/ruff-0.8.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:45a56f61b24682f6f6709636949ae8cc82ae229d8d773b4c76c09ec83964a95a", size = 10972997 }, 370 | { url = "https://files.pythonhosted.org/packages/be/31/dd0db1f4796bda30dea7592f106f3a67a8f00bcd3a50df889fbac58e2786/ruff-0.8.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:496dd38a53aa173481a7d8866bcd6451bd934d06976a2505028a50583e001b76", size = 11317760 }, 371 | { url = "https://files.pythonhosted.org/packages/d4/70/cfcb693dc294e034c6fed837fa2ec98b27cc97a26db5d049345364f504bf/ruff-0.8.6-py3-none-win32.whl", hash = "sha256:e169ea1b9eae61c99b257dc83b9ee6c76f89042752cb2d83486a7d6e48e8f764", size = 8799729 }, 372 | { url = "https://files.pythonhosted.org/packages/60/22/ae6bcaa0edc83af42751bd193138bfb7598b2990939d3e40494d6c00698c/ruff-0.8.6-py3-none-win_amd64.whl", hash = "sha256:f1d70bef3d16fdc897ee290d7d20da3cbe4e26349f62e8a0274e7a3f4ce7a905", size = 9673857 }, 373 | { url = "https://files.pythonhosted.org/packages/91/f8/3765e053acd07baa055c96b2065c7fab91f911b3c076dfea71006666f5b0/ruff-0.8.6-py3-none-win_arm64.whl", hash = "sha256:7d7fc2377a04b6e04ffe588caad613d0c460eb2ecba4c0ccbbfe2bc973cbc162", size = 9149556 }, 374 | ] 375 | 376 | [[package]] 377 | name = "sniffio" 378 | version = "1.3.1" 379 | source = { registry = "https://pypi.org/simple" } 380 | sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } 381 | wheels = [ 382 | { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, 383 | ] 384 | 385 | [[package]] 386 | name = "sse-starlette" 387 | version = "2.2.1" 388 | source = { registry = "https://pypi.org/simple" } 389 | dependencies = [ 390 | { name = "anyio" }, 391 | { name = "starlette" }, 392 | ] 393 | sdist = { url = "https://files.pythonhosted.org/packages/71/a4/80d2a11af59fe75b48230846989e93979c892d3a20016b42bb44edb9e398/sse_starlette-2.2.1.tar.gz", hash = "sha256:54470d5f19274aeed6b2d473430b08b4b379ea851d953b11d7f1c4a2c118b419", size = 17376 } 394 | wheels = [ 395 | { url = "https://files.pythonhosted.org/packages/d9/e0/5b8bd393f27f4a62461c5cf2479c75a2cc2ffa330976f9f00f5f6e4f50eb/sse_starlette-2.2.1-py3-none-any.whl", hash = "sha256:6410a3d3ba0c89e7675d4c273a301d64649c03a5ef1ca101f10b47f895fd0e99", size = 10120 }, 396 | ] 397 | 398 | [[package]] 399 | name = "starlette" 400 | version = "0.45.2" 401 | source = { registry = "https://pypi.org/simple" } 402 | dependencies = [ 403 | { name = "anyio" }, 404 | ] 405 | sdist = { url = "https://files.pythonhosted.org/packages/90/4f/e1c9f4ec3dae67a94c9285ed275355d5f7cf0f3a5c34538c8ae5412af550/starlette-0.45.2.tar.gz", hash = "sha256:bba1831d15ae5212b22feab2f218bab6ed3cd0fc2dc1d4442443bb1ee52260e0", size = 2574026 } 406 | wheels = [ 407 | { url = "https://files.pythonhosted.org/packages/aa/ab/fe4f57c83620b39dfc9e7687ebad59129ff05170b99422105019d9a65eec/starlette-0.45.2-py3-none-any.whl", hash = "sha256:4daec3356fb0cb1e723a5235e5beaf375d2259af27532958e2d79df549dad9da", size = 71505 }, 408 | ] 409 | 410 | [[package]] 411 | name = "tweepy" 412 | version = "4.14.0" 413 | source = { registry = "https://pypi.org/simple" } 414 | dependencies = [ 415 | { name = "oauthlib" }, 416 | { name = "requests" }, 417 | { name = "requests-oauthlib" }, 418 | ] 419 | sdist = { url = "https://files.pythonhosted.org/packages/75/1c/0db8c3cf9d31bf63853ff612d201060ae78e6db03468a70e063bef0eda62/tweepy-4.14.0.tar.gz", hash = "sha256:1f9f1707d6972de6cff6c5fd90dfe6a449cd2e0d70bd40043ffab01e07a06c8c", size = 88623 } 420 | wheels = [ 421 | { url = "https://files.pythonhosted.org/packages/4d/78/ba0065d5636bbf4a35b78c4f81b74e7858b609cdf69e629d6da5c91b9d92/tweepy-4.14.0-py3-none-any.whl", hash = "sha256:db6d3844ccc0c6d27f339f12ba8acc89912a961da513c1ae50fa2be502a56afb", size = 98520 }, 422 | ] 423 | 424 | [[package]] 425 | name = "typing-extensions" 426 | version = "4.12.2" 427 | source = { registry = "https://pypi.org/simple" } 428 | sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } 429 | wheels = [ 430 | { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, 431 | ] 432 | 433 | [[package]] 434 | name = "urllib3" 435 | version = "2.3.0" 436 | source = { registry = "https://pypi.org/simple" } 437 | sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 } 438 | wheels = [ 439 | { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, 440 | ] 441 | 442 | [[package]] 443 | name = "uvicorn" 444 | version = "0.34.0" 445 | source = { registry = "https://pypi.org/simple" } 446 | dependencies = [ 447 | { name = "click" }, 448 | { name = "h11" }, 449 | ] 450 | sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568 } 451 | wheels = [ 452 | { url = "https://files.pythonhosted.org/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", size = 62315 }, 453 | ] 454 | --------------------------------------------------------------------------------