├── .gitignore ├── README.md ├── docs ├── mcp-client-devs.md ├── mcp-server-devs.md └── mcp-server.md ├── linkedin_browser_mcp.py ├── requirements.txt └── test_linkedin_browser_mcp.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Python virtual environment 2 | env/ 3 | venv/ 4 | .env 5 | .venv/ 6 | 7 | # Playwright browser data 8 | **/playwright-downloads/ 9 | **/playwright/.cache/ 10 | **/playwright/driver/ 11 | **/playwright/node 12 | **/playwright/node_modules/ 13 | 14 | # Session data 15 | sessions/ 16 | *_cookies.json 17 | login_attempts.json 18 | 19 | # Python cache files 20 | __pycache__/ 21 | *.py[cod] 22 | *$py.class 23 | *.so 24 | .Python 25 | build/ 26 | develop-eggs/ 27 | dist/ 28 | downloads/ 29 | eggs/ 30 | .eggs/ 31 | lib/ 32 | lib64/ 33 | parts/ 34 | sdist/ 35 | var/ 36 | wheels/ 37 | *.egg-info/ 38 | .installed.cfg 39 | *.egg 40 | 41 | # IDE settings 42 | .vscode/ 43 | .idea/ 44 | *.swp 45 | *.swo 46 | 47 | # OS 48 | .DS_Store 49 | Thumbs.db 50 | 51 | # Logs 52 | *.log 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LinkedIn Browser MCP Server 2 | 3 | A FastMCP-based server for LinkedIn automation and data extraction using browser automation. This server provides a set of tools for interacting with LinkedIn programmatically while respecting LinkedIn's terms of service and rate limits. 4 | 5 | ## Features 6 | 7 | - **Secure Authentication** 8 | - Environment-based credential management 9 | - Session persistence with encrypted cookie storage 10 | - Rate limiting protection 11 | - Automatic session recovery 12 | 13 | - **Profile Operations** 14 | - View and extract profile information 15 | - Search for profiles based on keywords 16 | - Browse LinkedIn feed 17 | - Profile visiting capabilities 18 | 19 | - **Post Interactions** 20 | - Like posts 21 | - Comment on posts 22 | - Read post content and engagement metrics 23 | 24 | ## Prerequisites 25 | 26 | - Python 3.8+ 27 | - Playwright 28 | - FastMCP library 29 | - LinkedIn account 30 | 31 | ## Installation 32 | 33 | 1. Clone the repository: 34 | ```bash 35 | git clone [repository-url] 36 | cd mcp-linkedin-server 37 | ``` 38 | 39 | 2. Create and activate a virtual environment: 40 | ```bash 41 | python -m venv env 42 | source env/bin/activate # On Windows: env\Scripts\activate 43 | ``` 44 | 45 | 3. Install dependencies: 46 | ```bash 47 | pip install -r requirements.txt 48 | playwright install chromium 49 | ``` 50 | 51 | 4. Set up environment variables: 52 | Create a `.env` file in the root directory with: 53 | ```env 54 | LINKEDIN_USERNAME=your_email@example.com 55 | LINKEDIN_PASSWORD=your_password 56 | COOKIE_ENCRYPTION_KEY=your_encryption_key # Optional: will be auto-generated if not provided 57 | ``` 58 | 59 | ## Usage 60 | 61 | 1. Start the MCP server: 62 | ```bash 63 | python linkedin_browser_mcp.py 64 | ``` 65 | 66 | 2. Available Tools: 67 | 68 | - `login_linkedin_secure`: Securely log in using environment credentials 69 | - `browse_linkedin_feed`: Browse and extract posts from feed 70 | - `search_linkedin_profiles`: Search for profiles matching criteria 71 | - `view_linkedin_profile`: View and extract data from specific profiles 72 | - `interact_with_linkedin_post`: Like, comment, or read posts 73 | 74 | ### Example Usage 75 | 76 | ```python 77 | from fastmcp import FastMCP 78 | 79 | # Initialize client 80 | client = FastMCP.connect("http://localhost:8000") 81 | 82 | # Login 83 | result = await client.login_linkedin_secure() 84 | print(result) 85 | 86 | # Search profiles 87 | profiles = await client.search_linkedin_profiles( 88 | query="software engineer", 89 | count=5 90 | ) 91 | print(profiles) 92 | 93 | # View profile 94 | profile_data = await client.view_linkedin_profile( 95 | profile_url="https://www.linkedin.com/in/username" 96 | ) 97 | print(profile_data) 98 | ``` 99 | 100 | ## Security Features 101 | 102 | - Encrypted cookie storage 103 | - Rate limiting protection 104 | - Secure credential management 105 | - Session persistence 106 | - Browser automation security measures 107 | 108 | ## Best Practices 109 | 110 | 1. **Rate Limiting**: The server implements rate limiting to prevent excessive requests: 111 | - Maximum 5 login attempts per hour 112 | - Automatic session reuse 113 | - Cookie persistence to minimize login needs 114 | 115 | 2. **Error Handling**: Comprehensive error handling for: 116 | - Network issues 117 | - Authentication failures 118 | - LinkedIn security challenges 119 | - Invalid URLs or parameters 120 | 121 | 3. **Session Management**: 122 | - Automatic cookie encryption 123 | - Session persistence 124 | - Secure storage practices 125 | 126 | ## Contributing 127 | 128 | 1. Fork the repository 129 | 2. Create a feature branch 130 | 3. Commit your changes 131 | 4. Push to the branch 132 | 5. Create a Pull Request 133 | 134 | ## License 135 | 136 | MIT 137 | 138 | ## Disclaimer 139 | 140 | This tool is for educational purposes only. Ensure compliance with LinkedIn's terms of service and rate limiting guidelines when using this software. -------------------------------------------------------------------------------- /docs/mcp-client-devs.md: -------------------------------------------------------------------------------- 1 | For Client Developers 2 | Get started building your own client that can integrate with all MCP servers. 3 | 4 | In this tutorial, you’ll learn how to build a LLM-powered chatbot client that connects to MCP servers. It helps to have gone through the Server quickstart that guides you through the basic of building your first server. 5 | 6 | Python 7 | Java 8 | You can find the complete code for this tutorial here. 9 | 10 | System Requirements 11 | Before starting, ensure your system meets these requirements: 12 | 13 | Mac or Windows computer 14 | Latest Python version installed 15 | Latest version of uv installed 16 | Setting Up Your Environment 17 | First, create a new Python project with uv: 18 | 19 | 20 | # Create project directory 21 | uv init mcp-client 22 | cd mcp-client 23 | 24 | # Create virtual environment 25 | uv venv 26 | 27 | # Activate virtual environment 28 | # On Windows: 29 | .venv\Scripts\activate 30 | # On Unix or MacOS: 31 | source .venv/bin/activate 32 | 33 | # Install required packages 34 | uv add mcp anthropic python-dotenv 35 | 36 | # Remove boilerplate files 37 | rm hello.py 38 | 39 | # Create our main file 40 | touch client.py 41 | Setting Up Your API Key 42 | You’ll need an Anthropic API key from the Anthropic Console. 43 | 44 | Create a .env file to store it: 45 | 46 | 47 | # Create .env file 48 | touch .env 49 | Add your key to the .env file: 50 | 51 | 52 | ANTHROPIC_API_KEY= 53 | Add .env to your .gitignore: 54 | 55 | 56 | echo ".env" >> .gitignore 57 | Make sure you keep your ANTHROPIC_API_KEY secure! 58 | 59 | Creating the Client 60 | Basic Client Structure 61 | First, let’s set up our imports and create the basic client class: 62 | 63 | 64 | import asyncio 65 | from typing import Optional 66 | from contextlib import AsyncExitStack 67 | 68 | from mcp import ClientSession, StdioServerParameters 69 | from mcp.client.stdio import stdio_client 70 | 71 | from anthropic import Anthropic 72 | from dotenv import load_dotenv 73 | 74 | load_dotenv() # load environment variables from .env 75 | 76 | class MCPClient: 77 | def __init__(self): 78 | # Initialize session and client objects 79 | self.session: Optional[ClientSession] = None 80 | self.exit_stack = AsyncExitStack() 81 | self.anthropic = Anthropic() 82 | # methods will go here 83 | Server Connection Management 84 | Next, we’ll implement the method to connect to an MCP server: 85 | 86 | 87 | async def connect_to_server(self, server_script_path: str): 88 | """Connect to an MCP server 89 | 90 | Args: 91 | server_script_path: Path to the server script (.py or .js) 92 | """ 93 | is_python = server_script_path.endswith('.py') 94 | is_js = server_script_path.endswith('.js') 95 | if not (is_python or is_js): 96 | raise ValueError("Server script must be a .py or .js file") 97 | 98 | command = "python" if is_python else "node" 99 | server_params = StdioServerParameters( 100 | command=command, 101 | args=[server_script_path], 102 | env=None 103 | ) 104 | 105 | stdio_transport = await self.exit_stack.enter_async_context(stdio_client(server_params)) 106 | self.stdio, self.write = stdio_transport 107 | self.session = await self.exit_stack.enter_async_context(ClientSession(self.stdio, self.write)) 108 | 109 | await self.session.initialize() 110 | 111 | # List available tools 112 | response = await self.session.list_tools() 113 | tools = response.tools 114 | print("\nConnected to server with tools:", [tool.name for tool in tools]) 115 | Query Processing Logic 116 | Now let’s add the core functionality for processing queries and handling tool calls: 117 | 118 | 119 | async def process_query(self, query: str) -> str: 120 | """Process a query using Claude and available tools""" 121 | messages = [ 122 | { 123 | "role": "user", 124 | "content": query 125 | } 126 | ] 127 | 128 | response = await self.session.list_tools() 129 | available_tools = [{ 130 | "name": tool.name, 131 | "description": tool.description, 132 | "input_schema": tool.inputSchema 133 | } for tool in response.tools] 134 | 135 | # Initial Claude API call 136 | response = self.anthropic.messages.create( 137 | model="claude-3-5-sonnet-20241022", 138 | max_tokens=1000, 139 | messages=messages, 140 | tools=available_tools 141 | ) 142 | 143 | # Process response and handle tool calls 144 | tool_results = [] 145 | final_text = [] 146 | 147 | assistant_message_content = [] 148 | for content in response.content: 149 | if content.type == 'text': 150 | final_text.append(content.text) 151 | assistant_message_content.append(content) 152 | elif content.type == 'tool_use': 153 | tool_name = content.name 154 | tool_args = content.input 155 | 156 | # Execute tool call 157 | result = await self.session.call_tool(tool_name, tool_args) 158 | tool_results.append({"call": tool_name, "result": result}) 159 | final_text.append(f"[Calling tool {tool_name} with args {tool_args}]") 160 | 161 | assistant_message_content.append(content) 162 | messages.append({ 163 | "role": "assistant", 164 | "content": assistant_message_content 165 | }) 166 | messages.append({ 167 | "role": "user", 168 | "content": [ 169 | { 170 | "type": "tool_result", 171 | "tool_use_id": content.id, 172 | "content": result.content 173 | } 174 | ] 175 | }) 176 | 177 | # Get next response from Claude 178 | response = self.anthropic.messages.create( 179 | model="claude-3-5-sonnet-20241022", 180 | max_tokens=1000, 181 | messages=messages, 182 | tools=available_tools 183 | ) 184 | 185 | final_text.append(response.content[0].text) 186 | 187 | return "\n".join(final_text) 188 | Interactive Chat Interface 189 | Now we’ll add the chat loop and cleanup functionality: 190 | 191 | 192 | async def chat_loop(self): 193 | """Run an interactive chat loop""" 194 | print("\nMCP Client Started!") 195 | print("Type your queries or 'quit' to exit.") 196 | 197 | while True: 198 | try: 199 | query = input("\nQuery: ").strip() 200 | 201 | if query.lower() == 'quit': 202 | break 203 | 204 | response = await self.process_query(query) 205 | print("\n" + response) 206 | 207 | except Exception as e: 208 | print(f"\nError: {str(e)}") 209 | 210 | async def cleanup(self): 211 | """Clean up resources""" 212 | await self.exit_stack.aclose() 213 | Main Entry Point 214 | Finally, we’ll add the main execution logic: 215 | 216 | 217 | async def main(): 218 | if len(sys.argv) < 2: 219 | print("Usage: python client.py ") 220 | sys.exit(1) 221 | 222 | client = MCPClient() 223 | try: 224 | await client.connect_to_server(sys.argv[1]) 225 | await client.chat_loop() 226 | finally: 227 | await client.cleanup() 228 | 229 | if __name__ == "__main__": 230 | import sys 231 | asyncio.run(main()) 232 | You can find the complete client.py file here. 233 | 234 | Key Components Explained 235 | 1. Client Initialization 236 | The MCPClient class initializes with session management and API clients 237 | Uses AsyncExitStack for proper resource management 238 | Configures the Anthropic client for Claude interactions 239 | 2. Server Connection 240 | Supports both Python and Node.js servers 241 | Validates server script type 242 | Sets up proper communication channels 243 | Initializes the session and lists available tools 244 | 3. Query Processing 245 | Maintains conversation context 246 | Handles Claude’s responses and tool calls 247 | Manages the message flow between Claude and tools 248 | Combines results into a coherent response 249 | 4. Interactive Interface 250 | Provides a simple command-line interface 251 | Handles user input and displays responses 252 | Includes basic error handling 253 | Allows graceful exit 254 | 5. Resource Management 255 | Proper cleanup of resources 256 | Error handling for connection issues 257 | Graceful shutdown procedures 258 | Common Customization Points 259 | Tool Handling 260 | 261 | Modify process_query() to handle specific tool types 262 | Add custom error handling for tool calls 263 | Implement tool-specific response formatting 264 | Response Processing 265 | 266 | Customize how tool results are formatted 267 | Add response filtering or transformation 268 | Implement custom logging 269 | User Interface 270 | 271 | Add a GUI or web interface 272 | Implement rich console output 273 | Add command history or auto-completion 274 | Running the Client 275 | To run your client with any MCP server: 276 | 277 | 278 | uv run client.py path/to/server.py # python server 279 | uv run client.py path/to/build/index.js # node server 280 | If you’re continuing the weather tutorial from the server quickstart, your command might look something like this: python client.py .../weather/src/weather/server.py 281 | 282 | The client will: 283 | 284 | Connect to the specified server 285 | List available tools 286 | Start an interactive chat session where you can: 287 | Enter queries 288 | See tool executions 289 | Get responses from Claude 290 | Here’s an example of what it should look like if connected to the weather server from the server quickstart: 291 | 292 | 293 | How It Works 294 | When you submit a query: 295 | 296 | The client gets the list of available tools from the server 297 | Your query is sent to Claude along with tool descriptions 298 | Claude decides which tools (if any) to use 299 | The client executes any requested tool calls through the server 300 | Results are sent back to Claude 301 | Claude provides a natural language response 302 | The response is displayed to you 303 | Best practices 304 | Error Handling 305 | 306 | Always wrap tool calls in try-catch blocks 307 | Provide meaningful error messages 308 | Gracefully handle connection issues 309 | Resource Management 310 | 311 | Use AsyncExitStack for proper cleanup 312 | Close connections when done 313 | Handle server disconnections 314 | Security 315 | 316 | Store API keys securely in .env 317 | Validate server responses 318 | Be cautious with tool permissions 319 | Troubleshooting 320 | Server Path Issues 321 | Double-check the path to your server script is correct 322 | Use the absolute path if the relative path isn’t working 323 | For Windows users, make sure to use forward slashes (/) or escaped backslashes (\) in the path 324 | Verify the server file has the correct extension (.py for Python or .js for Node.js) 325 | Example of correct path usage: 326 | 327 | 328 | # Relative path 329 | uv run client.py ./server/weather.py 330 | 331 | # Absolute path 332 | uv run client.py /Users/username/projects/mcp-server/weather.py 333 | 334 | # Windows path (either format works) 335 | uv run client.py C:/projects/mcp-server/weather.py 336 | uv run client.py C:\\projects\\mcp-server\\weather.py 337 | Response Timing 338 | The first response might take up to 30 seconds to return 339 | This is normal and happens while: 340 | The server initializes 341 | Claude processes the query 342 | Tools are being executed 343 | Subsequent responses are typically faster 344 | Don’t interrupt the process during this initial waiting period 345 | Common Error Messages 346 | If you see: 347 | 348 | FileNotFoundError: Check your server path 349 | Connection refused: Ensure the server is running and the path is correct 350 | Tool execution failed: Verify the tool’s required environment variables are set 351 | Timeout error: Consider increasing the timeout in your client configuration -------------------------------------------------------------------------------- /docs/mcp-server-devs.md: -------------------------------------------------------------------------------- 1 | Quickstart 2 | For Server Developers 3 | Get started building your own server to use in Claude for Desktop and other clients. 4 | 5 | In this tutorial, we’ll build a simple MCP weather server and connect it to a host, Claude for Desktop. We’ll start with a basic setup, and then progress to more complex use cases. 6 | 7 | ​ 8 | What we’ll be building 9 | Many LLMs (including Claude) do not currently have the ability to fetch the forecast and severe weather alerts. Let’s use MCP to solve that! 10 | 11 | We’ll build a server that exposes two tools: get-alerts and get-forecast. Then we’ll connect the server to an MCP host (in this case, Claude for Desktop): 12 | 13 | 14 | 15 | Servers can connect to any client. We’ve chosen Claude for Desktop here for simplicity, but we also have guides on building your own client as well as a list of other clients here. 16 | 17 | 18 | Why Claude for Desktop and not Claude.ai? 19 | 20 | ​ 21 | Core MCP Concepts 22 | MCP servers can provide three main types of capabilities: 23 | 24 | Resources: File-like data that can be read by clients (like API responses or file contents) 25 | Tools: Functions that can be called by the LLM (with user approval) 26 | Prompts: Pre-written templates that help users accomplish specific tasks 27 | This tutorial will primarily focus on tools. 28 | 29 | Python 30 | Node 31 | Java 32 | Let’s get started with building our weather server! You can find the complete code for what we’ll be building here. 33 | 34 | Prerequisite knowledge 35 | This quickstart assumes you have familiarity with: 36 | 37 | Python 38 | LLMs like Claude 39 | System requirements 40 | Python 3.10 or higher installed. 41 | You must use the Python MCP SDK 1.2.0 or higher. 42 | Set up your environment 43 | First, let’s install uv and set up our Python project and environment: 44 | 45 | 46 | MacOS/Linux 47 | 48 | Windows 49 | 50 | curl -LsSf https://astral.sh/uv/install.sh | sh 51 | Make sure to restart your terminal afterwards to ensure that the uv command gets picked up. 52 | 53 | Now, let’s create and set up our project: 54 | 55 | 56 | MacOS/Linux 57 | 58 | Windows 59 | 60 | # Create a new directory for our project 61 | uv init weather 62 | cd weather 63 | 64 | # Create virtual environment and activate it 65 | uv venv 66 | source .venv/bin/activate 67 | 68 | # Install dependencies 69 | uv add "mcp[cli]" httpx 70 | 71 | # Create our server file 72 | touch weather.py 73 | Now let’s dive into building your server. 74 | 75 | Building your server 76 | Importing packages and setting up the instance 77 | Add these to the top of your weather.py: 78 | 79 | 80 | from typing import Any 81 | import httpx 82 | from mcp.server.fastmcp import FastMCP 83 | 84 | # Initialize FastMCP server 85 | mcp = FastMCP("weather") 86 | 87 | # Constants 88 | NWS_API_BASE = "https://api.weather.gov" 89 | USER_AGENT = "weather-app/1.0" 90 | The FastMCP class uses Python type hints and docstrings to automatically generate tool definitions, making it easy to create and maintain MCP tools. 91 | 92 | Helper functions 93 | Next, let’s add our helper functions for querying and formatting the data from the National Weather Service API: 94 | 95 | 96 | async def make_nws_request(url: str) -> dict[str, Any] | None: 97 | """Make a request to the NWS API with proper error handling.""" 98 | headers = { 99 | "User-Agent": USER_AGENT, 100 | "Accept": "application/geo+json" 101 | } 102 | async with httpx.AsyncClient() as client: 103 | try: 104 | response = await client.get(url, headers=headers, timeout=30.0) 105 | response.raise_for_status() 106 | return response.json() 107 | except Exception: 108 | return None 109 | 110 | def format_alert(feature: dict) -> str: 111 | """Format an alert feature into a readable string.""" 112 | props = feature["properties"] 113 | return f""" 114 | Event: {props.get('event', 'Unknown')} 115 | Area: {props.get('areaDesc', 'Unknown')} 116 | Severity: {props.get('severity', 'Unknown')} 117 | Description: {props.get('description', 'No description available')} 118 | Instructions: {props.get('instruction', 'No specific instructions provided')} 119 | """ 120 | Implementing tool execution 121 | The tool execution handler is responsible for actually executing the logic of each tool. Let’s add it: 122 | 123 | 124 | @mcp.tool() 125 | async def get_alerts(state: str) -> str: 126 | """Get weather alerts for a US state. 127 | 128 | Args: 129 | state: Two-letter US state code (e.g. CA, NY) 130 | """ 131 | url = f"{NWS_API_BASE}/alerts/active/area/{state}" 132 | data = await make_nws_request(url) 133 | 134 | if not data or "features" not in data: 135 | return "Unable to fetch alerts or no alerts found." 136 | 137 | if not data["features"]: 138 | return "No active alerts for this state." 139 | 140 | alerts = [format_alert(feature) for feature in data["features"]] 141 | return "\n---\n".join(alerts) 142 | 143 | @mcp.tool() 144 | async def get_forecast(latitude: float, longitude: float) -> str: 145 | """Get weather forecast for a location. 146 | 147 | Args: 148 | latitude: Latitude of the location 149 | longitude: Longitude of the location 150 | """ 151 | # First get the forecast grid endpoint 152 | points_url = f"{NWS_API_BASE}/points/{latitude},{longitude}" 153 | points_data = await make_nws_request(points_url) 154 | 155 | if not points_data: 156 | return "Unable to fetch forecast data for this location." 157 | 158 | # Get the forecast URL from the points response 159 | forecast_url = points_data["properties"]["forecast"] 160 | forecast_data = await make_nws_request(forecast_url) 161 | 162 | if not forecast_data: 163 | return "Unable to fetch detailed forecast." 164 | 165 | # Format the periods into a readable forecast 166 | periods = forecast_data["properties"]["periods"] 167 | forecasts = [] 168 | for period in periods[:5]: # Only show next 5 periods 169 | forecast = f""" 170 | {period['name']}: 171 | Temperature: {period['temperature']}°{period['temperatureUnit']} 172 | Wind: {period['windSpeed']} {period['windDirection']} 173 | Forecast: {period['detailedForecast']} 174 | """ 175 | forecasts.append(forecast) 176 | 177 | return "\n---\n".join(forecasts) 178 | Running the server 179 | Finally, let’s initialize and run the server: 180 | 181 | 182 | if __name__ == "__main__": 183 | # Initialize and run the server 184 | mcp.run(transport='stdio') 185 | Your server is complete! Run uv run weather.py to confirm that everything’s working. 186 | 187 | Let’s now test your server from an existing MCP host, Claude for Desktop. 188 | 189 | Testing your server with Claude for Desktop 190 | Claude for Desktop is not yet available on Linux. Linux users can proceed to the Building a client tutorial to build an MCP client that connects to the server we just built. 191 | 192 | First, make sure you have Claude for Desktop installed. You can install the latest version here. If you already have Claude for Desktop, make sure it’s updated to the latest version. 193 | 194 | We’ll need to configure Claude for Desktop for whichever MCP servers you want to use. To do this, open your Claude for Desktop App configuration at ~/Library/Application Support/Claude/claude_desktop_config.json in a text editor. Make sure to create the file if it doesn’t exist. 195 | 196 | For example, if you have VS Code installed: 197 | 198 | MacOS/Linux 199 | Windows 200 | 201 | code ~/Library/Application\ Support/Claude/claude_desktop_config.json 202 | You’ll then add your servers in the mcpServers key. The MCP UI elements will only show up in Claude for Desktop if at least one server is properly configured. 203 | 204 | In this case, we’ll add our single weather server like so: 205 | 206 | MacOS/Linux 207 | Windows 208 | Python 209 | 210 | { 211 | "mcpServers": { 212 | "weather": { 213 | "command": "uv", 214 | "args": [ 215 | "--directory", 216 | "/ABSOLUTE/PATH/TO/PARENT/FOLDER/weather", 217 | "run", 218 | "weather.py" 219 | ] 220 | } 221 | } 222 | } 223 | You may need to put the full path to the uv executable in the command field. You can get this by running which uv on MacOS/Linux or where uv on Windows. 224 | 225 | Make sure you pass in the absolute path to your server. 226 | 227 | This tells Claude for Desktop: 228 | 229 | There’s an MCP server named “weather” 230 | To launch it by running uv --directory /ABSOLUTE/PATH/TO/PARENT/FOLDER/weather run weather 231 | Save the file, and restart Claude for Desktop. 232 | 233 | ​ 234 | Test with commands 235 | Let’s make sure Claude for Desktop is picking up the two tools we’ve exposed in our weather server. You can do this by looking for the hammer icon: 236 | 237 | 238 | After clicking on the hammer icon, you should see two tools listed: 239 | 240 | 241 | If your server isn’t being picked up by Claude for Desktop, proceed to the Troubleshooting section for debugging tips. 242 | 243 | If the hammer icon has shown up, you can now test your server by running the following commands in Claude for Desktop: 244 | 245 | What’s the weather in Sacramento? 246 | What are the active weather alerts in Texas? 247 | 248 | 249 | Since this is the US National Weather service, the queries will only work for US locations. 250 | 251 | ​ 252 | What’s happening under the hood 253 | When you ask a question: 254 | 255 | The client sends your question to Claude 256 | Claude analyzes the available tools and decides which one(s) to use 257 | The client executes the chosen tool(s) through the MCP server 258 | The results are sent back to Claude 259 | Claude formulates a natural language response 260 | The response is displayed to you! -------------------------------------------------------------------------------- /docs/mcp-server.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alinaqi/mcp-linkedin-server/2fbdf3220b4b98607871dfa469c70050ffc55d89/docs/mcp-server.md -------------------------------------------------------------------------------- /linkedin_browser_mcp.py: -------------------------------------------------------------------------------- 1 | from fastmcp import FastMCP, Context 2 | from playwright.async_api import async_playwright 3 | import asyncio 4 | import os 5 | import json 6 | from dotenv import load_dotenv 7 | from cryptography.fernet import Fernet 8 | import time 9 | import logging 10 | import sys 11 | from pathlib import Path 12 | 13 | # Set up logging to stderr only 14 | logging.basicConfig( 15 | level=logging.DEBUG, 16 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', 17 | handlers=[logging.StreamHandler(sys.stderr)] 18 | ) 19 | 20 | logger = logging.getLogger(__name__) 21 | logger.setLevel(logging.DEBUG) 22 | 23 | def setup_sessions_directory(): 24 | """Set up the sessions directory with proper permissions""" 25 | try: 26 | sessions_dir = Path(__file__).parent / 'sessions' 27 | sessions_dir.mkdir(mode=0o777, parents=True, exist_ok=True) 28 | # Ensure the directory has the correct permissions even if it already existed 29 | os.chmod(sessions_dir, 0o777) 30 | logger.debug(f"Sessions directory set up at {sessions_dir} with full permissions") 31 | return True 32 | except Exception as e: 33 | logger.error(f"Failed to set up sessions directory: {str(e)}") 34 | return False 35 | 36 | # Load environment variables 37 | env_path = Path(__file__).parent / '.env' 38 | if env_path.exists(): 39 | load_dotenv(env_path) 40 | logger.debug(f"Loaded environment from {env_path}") 41 | else: 42 | logger.warning(f"No .env file found at {env_path}") 43 | 44 | # Create MCP server with required dependencies 45 | mcp = FastMCP( 46 | "linkedin", 47 | dependencies=[ 48 | "playwright==1.40.0", 49 | "python-dotenv>=0.19.0", 50 | "cryptography>=35.0.0", 51 | "httpx>=0.24.0" 52 | ], 53 | debug=True # Enable debug mode for better error reporting 54 | ) 55 | 56 | def report_progress(ctx, current, total, message=None): 57 | """Helper function to report progress with proper validation""" 58 | try: 59 | progress = min(1.0, current / total) if total > 0 else 0 60 | if message: 61 | ctx.info(message) 62 | logger.debug(f"Progress: {progress:.2%} - {message if message else ''}") 63 | except Exception as e: 64 | logger.error(f"Error reporting progress: {str(e)}") 65 | 66 | def handle_notification(ctx, notification_type, params=None): 67 | """Helper function to handle notifications with proper validation""" 68 | try: 69 | if notification_type == "initialized": 70 | logger.info("MCP Server initialized") 71 | if ctx: # Only call ctx.info if ctx is provided 72 | ctx.info("Server initialized and ready") 73 | elif notification_type == "cancelled": 74 | reason = params.get("reason", "Unknown reason") 75 | logger.warning(f"Operation cancelled: {reason}") 76 | if ctx: 77 | ctx.warning(f"Operation cancelled: {reason}") 78 | else: 79 | logger.debug(f"Notification: {notification_type} - {params}") 80 | except Exception as e: 81 | logger.error(f"Error handling notification: {str(e)}") 82 | 83 | # Helper to save cookies between sessions 84 | async def save_cookies(page, platform): 85 | """Save cookies with proper directory permissions""" 86 | try: 87 | cookies = await page.context.cookies() 88 | 89 | # Validate cookies 90 | if not cookies or not isinstance(cookies, list): 91 | raise ValueError("Invalid cookie format") 92 | 93 | # Add timestamp for expiration check 94 | cookie_data = { 95 | "timestamp": int(time.time()), 96 | "cookies": cookies 97 | } 98 | 99 | # Ensure sessions directory exists with proper permissions 100 | if not setup_sessions_directory(): 101 | raise Exception("Failed to set up sessions directory") 102 | 103 | # Encrypt cookies before saving 104 | key = os.getenv('COOKIE_ENCRYPTION_KEY', Fernet.generate_key()) 105 | f = Fernet(key) 106 | encrypted_data = f.encrypt(json.dumps(cookie_data).encode()) 107 | 108 | cookie_file = Path(__file__).parent / 'sessions' / f'{platform}_cookies.json' 109 | with open(cookie_file, 'wb') as f: 110 | f.write(encrypted_data) 111 | # Set file permissions to 666 (rw-rw-rw-) 112 | os.chmod(cookie_file, 0o666) 113 | 114 | except Exception as e: 115 | raise Exception(f"Failed to save cookies: {str(e)}") 116 | 117 | # Helper to load cookies 118 | async def load_cookies(context, platform): 119 | try: 120 | with open(f'sessions/{platform}_cookies.json', 'rb') as f: 121 | encrypted_data = f.read() 122 | 123 | # Decrypt cookies 124 | key = os.getenv('COOKIE_ENCRYPTION_KEY') 125 | if not key: 126 | return False 127 | 128 | f = Fernet(key) 129 | cookie_data = json.loads(f.decrypt(encrypted_data)) 130 | 131 | # Check cookie expiration (24 hours) 132 | if int(time.time()) - cookie_data["timestamp"] > 86400: 133 | os.remove(f'sessions/{platform}_cookies.json') 134 | return False 135 | 136 | await context.add_cookies(cookie_data["cookies"]) 137 | return True 138 | 139 | except FileNotFoundError: 140 | return False 141 | except Exception as e: 142 | # If there's any error loading cookies, delete the file and start fresh 143 | try: 144 | os.remove(f'sessions/{platform}_cookies.json') 145 | except: 146 | pass 147 | return False 148 | 149 | class BrowserSession: 150 | """Context manager for browser sessions with cookie persistence""" 151 | 152 | def __init__(self, platform='linkedin', headless=True, launch_timeout=30000, max_retries=3): 153 | logger.info(f"Initializing {platform} browser session (headless: {headless})") 154 | self.platform = platform 155 | self.headless = headless 156 | self.launch_timeout = launch_timeout 157 | self.max_retries = max_retries 158 | self.playwright = None 159 | self.browser = None 160 | self.context = None 161 | self._closed = False 162 | 163 | async def __aenter__(self): 164 | retry_count = 0 165 | last_error = None 166 | 167 | # Ensure sessions directory exists with proper permissions 168 | if not setup_sessions_directory(): 169 | raise Exception("Failed to set up sessions directory with proper permissions") 170 | 171 | while retry_count < self.max_retries and not self._closed: 172 | try: 173 | logger.info(f"Starting Playwright (attempt {retry_count + 1}/{self.max_retries})") 174 | 175 | # Ensure clean state 176 | await self._cleanup() 177 | 178 | # Initialize Playwright with timeout 179 | self.playwright = await asyncio.wait_for( 180 | async_playwright().start(), 181 | timeout=self.launch_timeout/1000 182 | ) 183 | 184 | # Launch browser with more generous timeout and retry logic 185 | launch_success = False 186 | for attempt in range(3): 187 | try: 188 | logger.info(f"Launching browser (sub-attempt {attempt + 1}/3)") 189 | self.browser = await self.playwright.chromium.launch( 190 | headless=self.headless, 191 | timeout=self.launch_timeout, 192 | args=[ 193 | '--disable-dev-shm-usage', 194 | '--no-sandbox', 195 | '--disable-blink-features=AutomationControlled', # Try to avoid detection 196 | '--start-maximized' # Start with maximized window 197 | ] 198 | ) 199 | launch_success = True 200 | break 201 | except Exception as e: 202 | logger.error(f"Browser launch sub-attempt {attempt + 1} failed: {str(e)}") 203 | await asyncio.sleep(2) # Increased delay between attempts 204 | 205 | if not launch_success: 206 | raise Exception("Failed to launch browser after 3 attempts") 207 | 208 | logger.info("Creating browser context") 209 | self.context = await self.browser.new_context( 210 | viewport={'width': 1280, 'height': 800}, 211 | user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36' 212 | ) 213 | 214 | # Try to load existing session 215 | logger.info("Attempting to load existing session") 216 | try: 217 | session_loaded = await load_cookies(self.context, self.platform) 218 | if session_loaded: 219 | logger.info("Existing session loaded successfully") 220 | else: 221 | logger.info("No existing session found or session expired") 222 | except Exception as cookie_error: 223 | logger.warning(f"Error loading cookies: {str(cookie_error)}") 224 | # Continue even if cookie loading fails 225 | 226 | return self 227 | 228 | except Exception as e: 229 | last_error = e 230 | retry_count += 1 231 | logger.error(f"Browser session initialization attempt {retry_count} failed: {str(e)}") 232 | 233 | # Cleanup on failure 234 | await self._cleanup() 235 | 236 | if retry_count < self.max_retries and not self._closed: 237 | await asyncio.sleep(2 * retry_count) # Exponential backoff 238 | else: 239 | logger.error("All browser session initialization attempts failed") 240 | raise Exception(f"Failed to initialize browser after {self.max_retries} attempts. Last error: {str(last_error)}") 241 | 242 | async def _cleanup(self): 243 | """Clean up browser resources""" 244 | if self.browser: 245 | try: 246 | await self.browser.close() 247 | except Exception as e: 248 | logger.error(f"Error closing browser: {str(e)}") 249 | if self.playwright: 250 | try: 251 | await self.playwright.stop() 252 | except Exception as e: 253 | logger.error(f"Error stopping playwright: {str(e)}") 254 | self.browser = None 255 | self.playwright = None 256 | self.context = None 257 | 258 | async def __aexit__(self, exc_type, exc_val, exc_tb): 259 | logger.info("Closing browser session") 260 | self._closed = True 261 | await self._cleanup() 262 | 263 | async def new_page(self, url=None): 264 | if self._closed: 265 | raise Exception("Browser session has been closed") 266 | 267 | page = await self.context.new_page() 268 | if url: 269 | try: 270 | await page.goto(url, wait_until='networkidle', timeout=30000) 271 | except Exception as e: 272 | logger.error(f"Error navigating to {url}: {str(e)}") 273 | raise 274 | return page 275 | 276 | async def save_session(self, page): 277 | if self._closed: 278 | raise Exception("Browser session has been closed") 279 | 280 | try: 281 | await save_cookies(page, self.platform) 282 | except Exception as e: 283 | logger.error(f"Error saving session: {str(e)}") 284 | raise 285 | 286 | @mcp.tool() 287 | async def login_linkedin(username: str | None = None, password: str | None = None, ctx: Context | None = None) -> dict: 288 | """Open LinkedIn login page in browser for manual login. 289 | Username and password are optional - if not provided, user will need to enter them manually.""" 290 | 291 | logger.info("Starting LinkedIn login with browser for manual login") 292 | 293 | # Create browser session with explicit window size and position 294 | async with BrowserSession(platform='linkedin', headless=False) as session: 295 | try: 296 | # Configure browser window 297 | page = await session.new_page() 298 | await page.set_viewport_size({'width': 1280, 'height': 800}) 299 | 300 | # Navigate to LinkedIn login 301 | await page.goto('https://www.linkedin.com/login', wait_until='networkidle') 302 | 303 | # Check if already logged in 304 | if 'feed' in page.url: 305 | await session.save_session(page) 306 | return {"status": "success", "message": "Already logged in"} 307 | 308 | if ctx: 309 | ctx.info("Please log in manually through the browser window...") 310 | ctx.info("The browser will wait for up to 5 minutes for you to complete the login.") 311 | logger.info("Waiting for manual login...") 312 | 313 | # Pre-fill credentials if provided 314 | try: 315 | if username: 316 | await page.fill('#username', username) 317 | if password: 318 | await page.fill('#password', password) 319 | except Exception as e: 320 | logger.warning(f"Failed to pre-fill credentials: {str(e)}") 321 | # Continue anyway - user can enter manually 322 | 323 | # Wait for successful login (feed page) 324 | try: 325 | await page.wait_for_url('**/feed/**', timeout=300000) # 5 minutes timeout 326 | if ctx: 327 | ctx.info("Login successful!") 328 | logger.info("Manual login successful") 329 | await session.save_session(page) 330 | # Keep browser open for a moment to show success 331 | await asyncio.sleep(3) 332 | return {"status": "success", "message": "Manual login successful"} 333 | except Exception as e: 334 | logger.error(f"Login timeout: {str(e)}") 335 | return { 336 | "status": "error", 337 | "message": "Login timeout. Please try again and complete login within 5 minutes." 338 | } 339 | 340 | except Exception as e: 341 | logger.error(f"Login process error: {str(e)}") 342 | return {"status": "error", "message": f"Login process error: {str(e)}"} 343 | 344 | @mcp.tool() 345 | async def login_linkedin_secure(ctx: Context | None = None) -> dict: 346 | """Open LinkedIn login page in browser for manual login using environment credentials as default values. 347 | 348 | Optional environment variables: 349 | - LINKEDIN_USERNAME: Your LinkedIn email/username (will be pre-filled if provided) 350 | - LINKEDIN_PASSWORD: Your LinkedIn password (will be pre-filled if provided) 351 | 352 | Returns: 353 | dict: Login status and message 354 | """ 355 | logger.info("Starting secure LinkedIn login") 356 | username = os.getenv('LINKEDIN_USERNAME', '').strip() 357 | password = os.getenv('LINKEDIN_PASSWORD', '').strip() 358 | 359 | # We'll pass the credentials to pre-fill them, but user can still modify them 360 | return await login_linkedin(username if username else None, password if password else None, ctx) 361 | 362 | @mcp.tool() 363 | async def get_linkedin_profile(username: str, ctx: Context) -> dict: 364 | """Get LinkedIn profile information""" 365 | async with BrowserSession(platform='linkedin', headless=False) as session: 366 | page = await session.new_page(f'https://www.linkedin.com/in/{username}') 367 | 368 | # Check if profile page loaded 369 | if 'profile' not in page.url: 370 | return {"status": "error", "message": "Profile page not found"} 371 | 372 | @mcp.tool() 373 | async def browse_linkedin_feed(ctx: Context, count: int = 5) -> dict: 374 | """Browse LinkedIn feed and return recent posts 375 | 376 | Args: 377 | ctx: MCP context for logging and progress reporting 378 | count: Number of posts to retrieve (default: 5) 379 | 380 | Returns: 381 | dict: Contains status, posts array, and any errors 382 | """ 383 | posts = [] 384 | errors = [] 385 | 386 | async with BrowserSession(platform='linkedin') as session: 387 | try: 388 | page = await session.new_page('https://www.linkedin.com/feed/') 389 | 390 | # Check if we're logged in 391 | if 'login' in page.url: 392 | return { 393 | "status": "error", 394 | "message": "Not logged in. Please run login_linkedin tool first" 395 | } 396 | 397 | ctx.info(f"Browsing feed for {count} posts...") 398 | 399 | # Scroll to load content 400 | for i in range(min(count, 20)): # Limit to reasonable number 401 | report_progress(ctx, i, count, f"Loading post {i+1}/{count}") 402 | 403 | try: 404 | # Wait for posts to be visible 405 | await page.wait_for_selector('.feed-shared-update-v2', timeout=5000) 406 | 407 | # Extract visible posts 408 | new_posts = await page.evaluate('''() => { 409 | return Array.from(document.querySelectorAll('.feed-shared-update-v2')) 410 | .map(post => { 411 | try { 412 | return { 413 | author: post.querySelector('.feed-shared-actor__name')?.innerText?.trim() || 'Unknown', 414 | headline: post.querySelector('.feed-shared-actor__description')?.innerText?.trim() || '', 415 | content: post.querySelector('.feed-shared-text')?.innerText?.trim() || '', 416 | timestamp: post.querySelector('.feed-shared-actor__sub-description')?.innerText?.trim() || '', 417 | likes: post.querySelector('.social-details-social-counts__reactions-count')?.innerText?.trim() || '0' 418 | }; 419 | } catch (e) { 420 | return null; 421 | } 422 | }) 423 | .filter(p => p !== null); 424 | }''') 425 | 426 | # Add new posts to our collection, avoiding duplicates 427 | for post in new_posts: 428 | if post not in posts: 429 | posts.append(post) 430 | 431 | if len(posts) >= count: 432 | break 433 | 434 | # Scroll down to load more content 435 | await page.evaluate('window.scrollBy(0, 800)') 436 | await page.wait_for_timeout(1000) # Wait for content to load 437 | 438 | except Exception as scroll_error: 439 | errors.append(f"Error during scroll {i}: {str(scroll_error)}") 440 | continue 441 | 442 | # Save session cookies 443 | await session.save_session(page) 444 | 445 | return { 446 | "status": "success", 447 | "posts": posts[:count], 448 | "count": len(posts), 449 | "errors": errors if errors else None 450 | } 451 | 452 | except Exception as e: 453 | return { 454 | "status": "error", 455 | "message": f"Failed to browse feed: {str(e)}", 456 | "posts": posts, 457 | "errors": errors 458 | } 459 | 460 | 461 | @mcp.tool() 462 | async def search_linkedin_profiles(query: str, ctx: Context, count: int = 5) -> dict: 463 | """Search for LinkedIn profiles matching a query""" 464 | async with BrowserSession(platform='linkedin') as session: 465 | try: 466 | search_url = f'https://www.linkedin.com/search/results/people/?keywords={query}' 467 | page = await session.new_page(search_url) 468 | 469 | # Check if we're logged in 470 | if 'login' in page.url: 471 | return { 472 | "status": "error", 473 | "message": "Not logged in. Please run login_linkedin tool first" 474 | } 475 | 476 | ctx.info(f"Searching for profiles matching: {query}") 477 | report_progress(ctx, 20, 100, "Loading search results...") 478 | 479 | # Wait for search results 480 | await page.wait_for_selector('.reusable-search__result-container', timeout=10000) 481 | ctx.info("Search results loaded") 482 | report_progress(ctx, 50, 100, "Extracting profile data...") 483 | 484 | # Extract profile data 485 | profiles = await page.evaluate('''(count) => { 486 | const results = []; 487 | const profileCards = document.querySelectorAll('.reusable-search__result-container'); 488 | 489 | for (let i = 0; i < Math.min(profileCards.length, count); i++) { 490 | const card = profileCards[i]; 491 | try { 492 | const profile = { 493 | name: card.querySelector('.entity-result__title-text a')?.innerText?.trim() || 'Unknown', 494 | headline: card.querySelector('.entity-result__primary-subtitle')?.innerText?.trim() || '', 495 | location: card.querySelector('.entity-result__secondary-subtitle')?.innerText?.trim() || '', 496 | profileUrl: card.querySelector('.app-aware-link')?.href || '', 497 | connectionDegree: card.querySelector('.dist-value')?.innerText?.trim() || '', 498 | snippet: card.querySelector('.entity-result__summary')?.innerText?.trim() || '' 499 | }; 500 | results.push(profile); 501 | } catch (e) { 502 | console.error("Error extracting profile", e); 503 | } 504 | } 505 | return results; 506 | }''', count) 507 | 508 | report_progress(ctx, 90, 100, "Saving session...") 509 | await session.save_session(page) 510 | report_progress(ctx, 100, 100, "Search complete") 511 | 512 | return { 513 | "status": "success", 514 | "profiles": profiles, 515 | "count": len(profiles), 516 | "query": query 517 | } 518 | 519 | except Exception as e: 520 | ctx.error(f"Profile search failed: {str(e)}") 521 | return { 522 | "status": "error", 523 | "message": f"Failed to search profiles: {str(e)}" 524 | } 525 | 526 | @mcp.tool() 527 | async def view_linkedin_profile(profile_url: str, ctx: Context) -> dict: 528 | """Visit and extract data from a specific LinkedIn profile""" 529 | if not ('linkedin.com/in/' in profile_url): 530 | return { 531 | "status": "error", 532 | "message": "Invalid LinkedIn profile URL. Should contain 'linkedin.com/in/'" 533 | } 534 | 535 | async with BrowserSession(platform='linkedin') as session: 536 | try: 537 | page = await session.new_page(profile_url) 538 | 539 | # Check if we're logged in 540 | if 'login' in page.url: 541 | return { 542 | "status": "error", 543 | "message": "Not logged in. Please run login_linkedin tool first" 544 | } 545 | 546 | ctx.info(f"Viewing profile: {profile_url}") 547 | 548 | # Wait for profile to load 549 | await page.wait_for_selector('.pv-top-card', timeout=10000) 550 | await ctx.report_progress(0.5, 1.0) 551 | 552 | # Extract profile information 553 | profile_data = await page.evaluate('''() => { 554 | const getData = (selector, property = 'innerText') => { 555 | const element = document.querySelector(selector); 556 | return element ? element[property].trim() : null; 557 | }; 558 | 559 | return { 560 | name: getData('.pv-top-card--list .text-heading-xlarge'), 561 | headline: getData('.pv-top-card--list .text-body-medium'), 562 | location: getData('.pv-top-card--list .text-body-small:not(.inline)'), 563 | connectionDegree: getData('.pv-top-card__connections-count .t-black--light'), 564 | about: getData('.pv-shared-text-with-see-more .inline-show-more-text'), 565 | experience: Array.from(document.querySelectorAll('#experience-section .pv-entity__summary-info')) 566 | .map(exp => ({ 567 | title: exp.querySelector('h3')?.innerText?.trim() || '', 568 | company: exp.querySelector('.pv-entity__secondary-title')?.innerText?.trim() || '', 569 | duration: exp.querySelector('.pv-entity__date-range span:not(.visually-hidden)')?.innerText?.trim() || '' 570 | })), 571 | education: Array.from(document.querySelectorAll('#education-section .pv-education-entity')) 572 | .map(edu => ({ 573 | school: edu.querySelector('.pv-entity__school-name')?.innerText?.trim() || '', 574 | degree: edu.querySelector('.pv-entity__degree-name .pv-entity__comma-item')?.innerText?.trim() || '', 575 | field: edu.querySelector('.pv-entity__fos .pv-entity__comma-item')?.innerText?.trim() || '', 576 | dates: edu.querySelector('.pv-entity__dates span:not(.visually-hidden)')?.innerText?.trim() || '' 577 | })) 578 | }; 579 | }''') 580 | 581 | await ctx.report_progress(1.0, 1.0) 582 | await session.save_session(page) 583 | 584 | return { 585 | "status": "success", 586 | "profile": profile_data, 587 | "url": profile_url 588 | } 589 | 590 | except Exception as e: 591 | ctx.error(f"Profile viewing failed: {str(e)}") 592 | return { 593 | "status": "error", 594 | "message": f"Failed to extract profile data: {str(e)}" 595 | } 596 | 597 | 598 | @mcp.tool() 599 | async def interact_with_linkedin_post(post_url: str, ctx: Context, action: str = "like", comment: str = None) -> dict: 600 | """Interact with a LinkedIn post (like, comment)""" 601 | if not ('linkedin.com/posts/' in post_url or 'linkedin.com/feed/update/' in post_url): 602 | return { 603 | "status": "error", 604 | "message": "Invalid LinkedIn post URL" 605 | } 606 | 607 | valid_actions = ["like", "comment", "read"] 608 | if action not in valid_actions: 609 | return { 610 | "status": "error", 611 | "message": f"Invalid action. Choose from: {', '.join(valid_actions)}" 612 | } 613 | 614 | async with BrowserSession(platform='linkedin', headless=False) as session: 615 | try: 616 | page = await session.new_page(post_url) 617 | 618 | # Check if we're logged in 619 | if 'login' in page.url: 620 | return { 621 | "status": "error", 622 | "message": "Not logged in. Please run login_linkedin tool first" 623 | } 624 | 625 | # Wait for post to load 626 | await page.wait_for_selector('.feed-shared-update-v2', timeout=10000) 627 | ctx.info(f"Post loaded, performing action: {action}") 628 | 629 | # Read post content 630 | post_content = await page.evaluate('''() => { 631 | const post = document.querySelector('.feed-shared-update-v2'); 632 | return { 633 | author: post.querySelector('.feed-shared-actor__name')?.innerText?.trim() || 'Unknown', 634 | content: post.querySelector('.feed-shared-text')?.innerText?.trim() || '', 635 | engagementCount: post.querySelector('.social-details-social-counts__reactions-count')?.innerText?.trim() || '0' 636 | }; 637 | }''') 638 | 639 | # Perform the requested action 640 | if action == "like": 641 | # Find and click like button if not already liked 642 | liked = await page.evaluate('''() => { 643 | const likeButton = document.querySelector('button.react-button__trigger'); 644 | const isLiked = likeButton.getAttribute('aria-pressed') === 'true'; 645 | if (!isLiked) { 646 | likeButton.click(); 647 | return true; 648 | } 649 | return false; 650 | }''') 651 | 652 | result = { 653 | "status": "success", 654 | "action": "like", 655 | "performed": liked, 656 | "message": "Successfully liked the post" if liked else "Post was already liked" 657 | } 658 | 659 | elif action == "comment" and comment: 660 | # Add comment to the post 661 | await page.click('button.comments-comment-box__trigger') # Open comment box 662 | await page.fill('.ql-editor', comment) 663 | await page.click('button.comments-comment-box__submit-button') # Submit comment 664 | 665 | # Wait for comment to appear 666 | await page.wait_for_timeout(2000) 667 | 668 | result = { 669 | "status": "success", 670 | "action": "comment", 671 | "message": "Comment posted successfully" 672 | } 673 | 674 | else: # action == "read" 675 | result = { 676 | "status": "success", 677 | "action": "read", 678 | "post": post_content 679 | } 680 | 681 | await session.save_session(page) 682 | return result 683 | 684 | except Exception as e: 685 | ctx.error(f"Post interaction failed: {str(e)}") 686 | return { 687 | "status": "error", 688 | "message": f"Failed to interact with post: {str(e)}" 689 | } 690 | 691 | 692 | 693 | if __name__ == "__main__": 694 | try: 695 | logger.debug("Starting LinkedIn MCP Server with debug logging") 696 | 697 | # Initialize MCP server with simple configuration 698 | try: 699 | handle_notification(None, "initialized") # Pass None for ctx during initialization 700 | mcp.run(transport='stdio') 701 | except KeyboardInterrupt: 702 | handle_notification(None, "cancelled", {"reason": "Server stopped by user"}) 703 | logger.info("Server stopped by user") 704 | except Exception as e: 705 | handle_notification(None, "cancelled", {"reason": str(e)}) 706 | logger.error(f"Server error: {str(e)}", exc_info=True) 707 | sys.exit(1) 708 | 709 | except Exception as e: 710 | logger.error(f"Startup error: {str(e)}", exc_info=True) 711 | sys.exit(1) 712 | 713 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | mcp>=1.2.0 2 | fastmcp>=0.4.1 3 | playwright==1.40.0 4 | python-dotenv>=0.19.0 5 | cryptography>=35.0.0 6 | asyncio>=3.4.3 7 | httpx>=0.24.0 -------------------------------------------------------------------------------- /test_linkedin_browser_mcp.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import os 3 | from linkedin_browser_mcp import ( 4 | BrowserSession, 5 | save_cookies, 6 | load_cookies, 7 | login_linkedin, 8 | login_linkedin_secure, 9 | browse_linkedin_feed, 10 | search_linkedin_profiles, 11 | view_linkedin_profile, 12 | interact_with_linkedin_post 13 | ) 14 | 15 | class MockContext: 16 | def info(self, message): 17 | print(f"INFO: {message}") 18 | 19 | def error(self, message): 20 | print(f"ERROR: {message}") 21 | 22 | async def report_progress(self, current, total): 23 | print(f"Progress: {current}/{total}") 24 | 25 | @pytest.mark.asyncio 26 | async def test_browser_session(): 27 | async with BrowserSession(platform='linkedin', headless=True) as session: 28 | page = await session.new_page() 29 | assert page is not None 30 | assert session.browser is not None 31 | assert session.context is not None 32 | 33 | @pytest.mark.asyncio 34 | async def test_login_linkedin_secure_missing_credentials(): 35 | ctx = MockContext() 36 | # Clear environment variables 37 | if 'LINKEDIN_USERNAME' in os.environ: 38 | del os.environ['LINKEDIN_USERNAME'] 39 | if 'LINKEDIN_PASSWORD' in os.environ: 40 | del os.environ['LINKEDIN_PASSWORD'] 41 | result = await login_linkedin_secure(ctx) 42 | assert result["status"] == "error" 43 | assert "Missing LinkedIn credentials" in result["message"] 44 | 45 | @pytest.mark.asyncio 46 | async def test_login_linkedin_secure_invalid_email(): 47 | ctx = MockContext() 48 | os.environ['LINKEDIN_USERNAME'] = 'invalid-email' 49 | os.environ['LINKEDIN_PASSWORD'] = 'password123' 50 | result = await login_linkedin_secure(ctx) 51 | assert result["status"] == "error" 52 | assert "Invalid email format" in result["message"] 53 | 54 | @pytest.mark.asyncio 55 | async def test_login_linkedin_secure_short_password(): 56 | ctx = MockContext() 57 | os.environ['LINKEDIN_USERNAME'] = 'test@example.com' 58 | os.environ['LINKEDIN_PASSWORD'] = 'short' 59 | result = await login_linkedin_secure(ctx) 60 | assert result["status"] == "error" 61 | assert "password must be at least 8 characters" in result["message"] 62 | 63 | @pytest.mark.asyncio 64 | async def test_view_linkedin_profile_invalid_url(): 65 | ctx = MockContext() 66 | result = await view_linkedin_profile("https://invalid-url.com", ctx) 67 | assert result["status"] == "error" 68 | assert "Invalid LinkedIn profile URL" in result["message"] 69 | 70 | @pytest.mark.asyncio 71 | async def test_interact_with_linkedin_post_invalid_url(): 72 | ctx = MockContext() 73 | result = await interact_with_linkedin_post("https://invalid-url.com", ctx) 74 | assert result["status"] == "error" 75 | assert "Invalid LinkedIn post URL" in result["message"] 76 | 77 | @pytest.mark.asyncio 78 | async def test_interact_with_linkedin_post_invalid_action(): 79 | ctx = MockContext() 80 | result = await interact_with_linkedin_post( 81 | "https://linkedin.com/posts/123", 82 | ctx, 83 | action="invalid" 84 | ) 85 | assert result["status"] == "error" 86 | assert "Invalid action" in result["message"] --------------------------------------------------------------------------------