From 84cf29f0da8eb548d19ea79ecf8b91d14b26229b Mon Sep 17 00:00:00 2001 From: Daniel Sticker Date: Sat, 5 Jul 2025 16:34:34 -0400 Subject: [PATCH 1/9] feat(cookie): implement LinkedIn cookie extraction and usage Add functionality to extract LinkedIn session cookies for Docker setup. Introduce a new command-line flag `--get-cookie` to facilitate cookie retrieval. Update configuration to support cookie management via environment variables and keyring. Enhance login process to prioritize cookie authentication, falling back to credentials if necessary. Update manifest and schema to reflect changes in cookie handling. --- linkedin_mcp_server/config/loaders.py | 22 +++ linkedin_mcp_server/config/providers.py | 22 +++ linkedin_mcp_server/config/schema.py | 2 + linkedin_mcp_server/config/secrets.py | 91 +++++++++++- linkedin_mcp_server/drivers/chrome.py | 181 ++++++++++++++++++++++-- main.py | 60 ++++++++ manifest.json | 16 +-- 7 files changed, 370 insertions(+), 24 deletions(-) diff --git a/linkedin_mcp_server/config/loaders.py b/linkedin_mcp_server/config/loaders.py index ca1ea27..87735cc 100644 --- a/linkedin_mcp_server/config/loaders.py +++ b/linkedin_mcp_server/config/loaders.py @@ -33,6 +33,9 @@ def load_from_env(config: AppConfig) -> AppConfig: if password := os.environ.get("LINKEDIN_PASSWORD"): config.linkedin.password = password + if cookie := os.environ.get("LINKEDIN_COOKIE"): + config.linkedin.cookie = cookie + # ChromeDriver configuration if chromedriver := os.environ.get("CHROMEDRIVER"): config.chrome.chromedriver_path = chromedriver @@ -124,6 +127,18 @@ def load_from_args(config: AppConfig) -> AppConfig: help="Specify the path to the ChromeDriver executable", ) + parser.add_argument( + "--get-cookie", + action="store_true", + help="Login with credentials and display cookie for Docker setup", + ) + + parser.add_argument( + "--cookie", + type=str, + help="Specify LinkedIn cookie directly", + ) + args = parser.parse_args() # Update configuration with parsed arguments @@ -157,6 +172,13 @@ def load_from_args(config: AppConfig) -> AppConfig: if args.chromedriver: config.chrome.chromedriver_path = args.chromedriver + if hasattr(args, "get_cookie") and args.get_cookie: + config.server.get_cookie = True + config.chrome.non_interactive = True + + if args.cookie: + config.linkedin.cookie = args.cookie + return config diff --git a/linkedin_mcp_server/config/providers.py b/linkedin_mcp_server/config/providers.py index 31a3977..aab3054 100644 --- a/linkedin_mcp_server/config/providers.py +++ b/linkedin_mcp_server/config/providers.py @@ -10,6 +10,7 @@ SERVICE_NAME = "linkedin_mcp_server" EMAIL_KEY = "linkedin_email" PASSWORD_KEY = "linkedin_password" +COOKIE_KEY = "linkedin_cookie" logger = logging.getLogger(__name__) @@ -74,6 +75,27 @@ def clear_credentials_from_keyring() -> bool: return False +def get_cookie_from_keyring() -> Optional[str]: + """Retrieve LinkedIn cookie from system keyring.""" + return get_secret_from_keyring(COOKIE_KEY) + + +def save_cookie_to_keyring(cookie: str) -> bool: + """Save LinkedIn cookie to system keyring.""" + return set_secret_in_keyring(COOKIE_KEY, cookie) + + +def clear_cookie_from_keyring() -> bool: + """Clear stored cookie from the keyring.""" + try: + keyring.delete_password(SERVICE_NAME, COOKIE_KEY) + logger.info(f"Cookie removed from {get_keyring_name()}") + return True + except KeyringError as e: + logger.error(f"Error clearing cookie: {e}") + return False + + def get_chromedriver_paths() -> List[str]: """Get possible ChromeDriver paths based on the platform.""" paths = [ diff --git a/linkedin_mcp_server/config/schema.py b/linkedin_mcp_server/config/schema.py index 55d912f..cf3e012 100644 --- a/linkedin_mcp_server/config/schema.py +++ b/linkedin_mcp_server/config/schema.py @@ -19,6 +19,7 @@ class LinkedInConfig: email: Optional[str] = None password: Optional[str] = None + cookie: Optional[str] = None use_keyring: bool = True @@ -30,6 +31,7 @@ class ServerConfig: lazy_init: bool = True debug: bool = False setup: bool = True + get_cookie: bool = False # HTTP transport configuration host: str = "127.0.0.1" port: int = 8000 diff --git a/linkedin_mcp_server/config/secrets.py b/linkedin_mcp_server/config/secrets.py index bafe01c..8857e40 100644 --- a/linkedin_mcp_server/config/secrets.py +++ b/linkedin_mcp_server/config/secrets.py @@ -11,13 +11,59 @@ get_credentials_from_keyring, get_keyring_name, save_credentials_to_keyring, + get_cookie_from_keyring, + save_cookie_to_keyring, ) logger = logging.getLogger(__name__) +def has_authentication() -> bool: + """Check if authentication is available without triggering interactive setup.""" + config = get_config() + + # Check environment variable + if config.linkedin.cookie: + return True + + # Check keyring if enabled + if config.linkedin.use_keyring: + cookie = get_cookie_from_keyring() + if cookie: + return True + + return False + + +def get_authentication() -> str: + """Get LinkedIn cookie from keyring, environment, or interactive setup.""" + config = get_config() + + # First, try environment variable + if config.linkedin.cookie: + logger.info("Using LinkedIn cookie from environment") + return config.linkedin.cookie + + # Second, try keyring if enabled + if config.linkedin.use_keyring: + cookie = get_cookie_from_keyring() + if cookie: + logger.info(f"Using LinkedIn cookie from {get_keyring_name()}") + return cookie + + # If in non-interactive mode and no cookie found, raise error + if config.chrome.non_interactive: + raise CredentialsNotFoundError( + "No LinkedIn cookie found. Please provide cookie via " + "environment variable (LINKEDIN_COOKIE) or run with --get-cookie to obtain one." + ) + + # Otherwise, prompt for cookie or setup + return prompt_for_authentication() + + def get_credentials() -> Dict[str, str]: - """Get LinkedIn credentials from config, keyring, or prompt.""" + """Get LinkedIn credentials from config, keyring, or prompt (legacy for --get-cookie).""" config = get_config() # First, try configuration (includes environment variables) @@ -43,6 +89,49 @@ def get_credentials() -> Dict[str, str]: return prompt_for_credentials() +def prompt_for_authentication() -> str: + """Prompt user for LinkedIn cookie or setup via login.""" + print("šŸ”— LinkedIn MCP Server Setup") + + # Ask if user has a cookie + has_cookie = inquirer.confirm("Do you have a LinkedIn cookie?", default=False) + + if has_cookie: + cookie = inquirer.text("LinkedIn Cookie", validate=lambda _, x: len(x) > 10) + if save_cookie_to_keyring(cookie): + logger.info(f"Cookie stored securely in {get_keyring_name()}") + else: + logger.warning("Could not store cookie in system keyring.") + logger.info("Your cookie will only be used for this session.") + return cookie + else: + # Login flow to get cookie + return setup_cookie_from_login() + + +def setup_cookie_from_login() -> str: + """Login with credentials and capture cookie.""" + from linkedin_mcp_server.drivers.chrome import setup_driver_for_cookie_capture + + print("šŸ”‘ LinkedIn login required to obtain cookie") + credentials = prompt_for_credentials() + + # Use special driver setup for cookie capture + cookie = setup_driver_for_cookie_capture( + credentials["email"], credentials["password"] + ) + + if cookie: + if save_cookie_to_keyring(cookie): + logger.info(f"Cookie stored securely in {get_keyring_name()}") + else: + logger.warning("Could not store cookie in system keyring.") + logger.info("Your cookie will only be used for this session.") + return cookie + else: + raise CredentialsNotFoundError("Failed to obtain LinkedIn cookie") + + def prompt_for_credentials() -> Dict[str, str]: """Prompt user for LinkedIn credentials and store them securely.""" print(f"šŸ”‘ LinkedIn credentials required (will be stored in {get_keyring_name()})") diff --git a/linkedin_mcp_server/drivers/chrome.py b/linkedin_mcp_server/drivers/chrome.py index 3c21db2..2842638 100644 --- a/linkedin_mcp_server/drivers/chrome.py +++ b/linkedin_mcp_server/drivers/chrome.py @@ -25,8 +25,15 @@ from selenium.webdriver.chrome.service import Service from linkedin_mcp_server.config import get_config -from linkedin_mcp_server.config.providers import clear_credentials_from_keyring -from linkedin_mcp_server.config.secrets import get_credentials +from linkedin_mcp_server.config.providers import ( + clear_credentials_from_keyring, + clear_cookie_from_keyring, +) +from linkedin_mcp_server.config.secrets import ( + get_authentication, + get_credentials, + has_authentication, +) from linkedin_mcp_server.exceptions import ( CredentialsNotFoundError, DriverInitializationError, @@ -145,9 +152,116 @@ def get_or_create_driver() -> Optional[webdriver.Chrome]: raise WebDriverException(error_msg) +def login_with_cookie(driver: webdriver.Chrome, cookie: str) -> bool: + """ + Log in to LinkedIn using session cookie. + + Args: + driver: Chrome WebDriver instance + cookie: LinkedIn session cookie + + Returns: + bool: True if login was successful, False otherwise + """ + try: + from linkedin_scraper import actions # type: ignore + + # Use linkedin-scraper cookie login + actions.login(driver, cookie=cookie) + + # Verify login by checking current URL + current_url = driver.current_url + if ( + "feed" in current_url + or "mynetwork" in current_url + or "linkedin.com/in/" in current_url + ): + return True + else: + return False + except Exception as e: + logger.warning(f"Cookie authentication failed: {e}") + return False + + +def capture_session_cookie(driver: webdriver.Chrome) -> Optional[str]: + """ + Capture LinkedIn session cookie from driver. + + Args: + driver: Chrome WebDriver instance + + Returns: + Optional[str]: Session cookie if found, None otherwise + """ + try: + # Get li_at cookie which is the main LinkedIn session cookie + cookie = driver.get_cookie("li_at") + if cookie and cookie.get("value"): + return f"li_at={cookie['value']}" + return None + except Exception as e: + logger.warning(f"Failed to capture session cookie: {e}") + return None + + +def setup_driver_for_cookie_capture(email: str, password: str) -> Optional[str]: + """ + Setup a temporary driver to login and capture cookie. + + Args: + email: LinkedIn email + password: LinkedIn password + + Returns: + Optional[str]: Captured cookie if successful, None otherwise + """ + config = get_config() + + # Set up Chrome options for cookie capture + chrome_options = Options() + if config.chrome.headless: + chrome_options.add_argument("--headless=new") + + # Add essential options + chrome_options.add_argument("--no-sandbox") + chrome_options.add_argument("--disable-dev-shm-usage") + chrome_options.add_argument("--disable-gpu") + chrome_options.add_argument("--window-size=1920,1080") + + try: + # Create temporary driver + driver = webdriver.Chrome(options=chrome_options) + driver.set_page_load_timeout(60) + + # Login using linkedin-scraper + from linkedin_scraper import actions # type: ignore + + actions.login( + driver, + email, + password, + interactive=not config.chrome.non_interactive, + ) + + # Capture cookie + cookie = capture_session_cookie(driver) + + # Clean up + driver.quit() + + return cookie + + except Exception as e: + logger.error(f"Failed to capture cookie: {e}") + if "driver" in locals(): + driver.quit() + return None + + def login_to_linkedin(driver: webdriver.Chrome) -> bool: """ - Log in to LinkedIn using stored or provided credentials. + Log in to LinkedIn using cookie-first authentication. Args: driver: Chrome WebDriver instance @@ -160,7 +274,27 @@ def login_to_linkedin(driver: webdriver.Chrome) -> bool: """ config = get_config() - # Get LinkedIn credentials from config + # Try cookie authentication first + try: + cookie = get_authentication() + if login_with_cookie(driver, cookie): + logger.info("Successfully logged in to LinkedIn using cookie") + return True + else: + # Cookie login failed - clear invalid cookie from keyring + logger.warning( + "Cookie authentication failed - cookie may be expired or invalid" + ) + clear_cookie_from_keyring() + except CredentialsNotFoundError: + # No cookie available, fall back to credentials + pass + except Exception as e: + logger.warning(f"Cookie authentication failed: {e}") + # Clear invalid cookie from keyring + clear_cookie_from_keyring() + + # Fallback to credential-based login try: credentials = get_credentials() except CredentialsNotFoundError as e: @@ -172,10 +306,10 @@ def login_to_linkedin(driver: webdriver.Chrome) -> bool: credentials = prompt_for_credentials() if not credentials: - raise CredentialsNotFoundError("No credentials available") + raise CredentialsNotFoundError("No authentication method available") # Login to LinkedIn using enhanced linkedin-scraper - logger.info("Logging in to LinkedIn...") + logger.info("Logging in to LinkedIn with credentials...") from linkedin_scraper import actions # type: ignore @@ -189,6 +323,15 @@ def login_to_linkedin(driver: webdriver.Chrome) -> bool: ) logger.info("Successfully logged in to LinkedIn") + + # Capture cookie for future use + cookie = capture_session_cookie(driver) + if cookie: + from linkedin_mcp_server.config.providers import save_cookie_to_keyring + + save_cookie_to_keyring(cookie) + logger.info("Session cookie captured and stored") + return True except Exception: @@ -210,6 +353,15 @@ def login_to_linkedin(driver: webdriver.Chrome) -> bool: elif "feed" in current_url or "mynetwork" in current_url: # Actually logged in successfully despite the exception logger.info("Successfully logged in to LinkedIn") + + # Capture cookie for future use + cookie = capture_session_cookie(driver) + if cookie: + from linkedin_mcp_server.config.providers import save_cookie_to_keyring + + save_cookie_to_keyring(cookie) + logger.info("Session cookie captured and stored") + return True else: @@ -275,14 +427,21 @@ def initialize_driver() -> None: logger.info( "Using lazy initialization - driver will be created on first tool call" ) - if config.linkedin.email and config.linkedin.password: - logger.info("LinkedIn credentials found in configuration") + if has_authentication(): + logger.info("LinkedIn authentication found in configuration") else: - logger.info( - "No LinkedIn credentials found - will look for stored credentials on first use" - ) + logger.info("No LinkedIn authentication found - will set up on first use") return + # Pre-check authentication availability to trigger setup if needed + if not config.chrome.non_interactive and not has_authentication(): + # In interactive mode without authentication, trigger setup first + logger.info("Setting up LinkedIn authentication...") + try: + get_authentication() # This will trigger the interactive setup + except CredentialsNotFoundError: + pass # Setup was cancelled or failed, continue to driver creation + # Validate chromedriver can be found if config.chrome.chromedriver_path: logger.info(f"āœ… ChromeDriver found at: {config.chrome.chromedriver_path}") diff --git a/main.py b/main.py index d070325..747eadc 100644 --- a/main.py +++ b/main.py @@ -46,6 +46,62 @@ def choose_transport_interactive() -> Literal["stdio", "streamable-http"]: return answers["transport"] +def get_cookie_and_exit() -> None: + """Get LinkedIn cookie and exit (for Docker setup).""" + print("šŸ”— LinkedIn MCP Server - Cookie Extraction šŸ”—") + print("=" * 50) + + config = get_config() + + # Configure logging + configure_logging( + debug=config.server.debug, + json_format=config.chrome.non_interactive, + ) + + try: + from linkedin_mcp_server.config.secrets import get_credentials + from linkedin_mcp_server.drivers.chrome import setup_driver_for_cookie_capture + + # Get credentials + credentials = get_credentials() + + print("šŸ”‘ Logging in to LinkedIn...") + cookie = setup_driver_for_cookie_capture( + credentials["email"], credentials["password"] + ) + + if cookie: + print("āœ… Login successful!") + print(f"šŸŖ LinkedIn Cookie: {cookie}") + + # Try to copy to clipboard + try: + import pyperclip + + pyperclip.copy(cookie) + print("šŸ“‹ Cookie copied to clipboard!") + except Exception as e: + logger.warning(f"Could not copy to clipboard: {e}") + print("āš ļø Copy the cookie above manually") + + print("\nšŸ“ Usage:") + print("1. Copy the cookie above") + print("2. Set LINKEDIN_COOKIE environment variable in your Docker setup") + print("3. Or paste into Claude Desktop configuration") + + else: + print("āŒ Failed to obtain cookie") + sys.exit(1) + + except Exception as e: + logger.error(f"Error getting cookie: {e}") + print(f"āŒ Error getting cookie: {e}") + sys.exit(1) + + sys.exit(0) + + def main() -> None: """Initialize and run the LinkedIn MCP server.""" print("šŸ”— LinkedIn MCP Server šŸ”—") @@ -54,6 +110,10 @@ def main() -> None: # Get configuration using the new centralized system config = get_config() + # Handle --get-cookie flag + if config.server.get_cookie: + get_cookie_and_exit() + # Configure logging configure_logging( debug=config.server.debug, diff --git a/manifest.json b/manifest.json index 7f49b0d..0311c43 100644 --- a/manifest.json +++ b/manifest.json @@ -24,8 +24,7 @@ "command": "docker", "args": [ "run", "-i", "--rm", - "-e", "LINKEDIN_EMAIL=${user_config.linkedin_email}", - "-e", "LINKEDIN_PASSWORD=${user_config.linkedin_password}", + "-e", "LINKEDIN_COOKIE=${user_config.linkedin_cookie}", "stickerdaniel/linkedin-mcp-server" ] } @@ -84,16 +83,9 @@ } ], "user_config": { - "linkedin_email": { - "title": "LinkedIn Email", - "description": "Your LinkedIn account email address", - "type": "string", - "required": true, - "sensitive": false - }, - "linkedin_password": { - "title": "LinkedIn Password", - "description": "Your LinkedIn account password", + "linkedin_cookie": { + "title": "LinkedIn Cookie", + "description": "LinkedIn session cookie. Run 'docker run -it --rm -e LINKEDIN_EMAIL=your@email.com -e LINKEDIN_PASSWORD=yourpass stickerdaniel/linkedin-mcp-server --get-cookie' to obtain", "type": "string", "required": true, "sensitive": true From 5a3471fedc5e242bc3f360e92f4d704fa4bb9536 Mon Sep 17 00:00:00 2001 From: Daniel Sticker Date: Sun, 6 Jul 2025 04:56:53 -0400 Subject: [PATCH 2/9] feat(authentication): implement phased authentication setup Introduce a structured approach to LinkedIn authentication with clear phase separation: Authentication Setup, Driver Management, and Server Runtime. Enhance error handling and logging during the authentication process. Update driver initialization to utilize session cookies effectively, improving overall user experience and reliability. Refactor existing code to streamline driver management and ensure proper cleanup of resources. Update error messages for clarity and consistency. --- linkedin_mcp_server/authentication.py | 171 ++++++++ linkedin_mcp_server/config/__init__.py | 9 +- linkedin_mcp_server/config/loaders.py | 5 +- linkedin_mcp_server/config/secrets.py | 6 +- linkedin_mcp_server/drivers/chrome.py | 552 +++++++------------------ linkedin_mcp_server/error_handler.py | 17 +- linkedin_mcp_server/logging_config.py | 10 +- linkedin_mcp_server/setup.py | 337 +++++++++++++++ main.py | 305 ++++++++++---- uv.lock | 2 +- 10 files changed, 900 insertions(+), 514 deletions(-) create mode 100644 linkedin_mcp_server/authentication.py create mode 100644 linkedin_mcp_server/setup.py diff --git a/linkedin_mcp_server/authentication.py b/linkedin_mcp_server/authentication.py new file mode 100644 index 0000000..fb3ea82 --- /dev/null +++ b/linkedin_mcp_server/authentication.py @@ -0,0 +1,171 @@ +# linkedin_mcp_server/authentication.py +""" +Pure authentication module for LinkedIn MCP Server. + +This module handles authentication without any driver dependencies. +""" + +import logging + +from linkedin_mcp_server.config import get_config +from linkedin_mcp_server.config.providers import ( + get_cookie_from_keyring, + save_cookie_to_keyring, + clear_cookie_from_keyring, +) +from linkedin_mcp_server.exceptions import CredentialsNotFoundError + +# Constants for cookie validation +MIN_COOKIE_LENGTH = 20 +MIN_RAW_COOKIE_LENGTH = 10 + +logger = logging.getLogger(__name__) + + +def has_authentication() -> bool: + """ + Check if authentication is available without triggering setup. + + Returns: + bool: True if authentication (cookie) is available, False otherwise + """ + config = get_config() + + # Check environment variable + if config.linkedin.cookie: + return True + + # Check keyring if enabled + if config.linkedin.use_keyring: + cookie = get_cookie_from_keyring() + if cookie: + return True + + return False + + +def get_authentication() -> str: + """ + Get LinkedIn cookie from available sources. + + Returns: + str: LinkedIn session cookie + + Raises: + CredentialsNotFoundError: If no authentication is available + """ + config = get_config() + + # First, try environment variable + if config.linkedin.cookie: + logger.info("Using LinkedIn cookie from environment") + return config.linkedin.cookie + + # Second, try keyring if enabled + if config.linkedin.use_keyring: + cookie = get_cookie_from_keyring() + if cookie: + logger.info("Using LinkedIn cookie from keyring") + return cookie + + # No authentication available + raise CredentialsNotFoundError("No LinkedIn cookie found") + + +def store_authentication(cookie: str) -> bool: + """ + Store LinkedIn cookie securely. + + Args: + cookie: LinkedIn session cookie to store + + Returns: + bool: True if storage was successful, False otherwise + """ + config = get_config() + + if config.linkedin.use_keyring: + success = save_cookie_to_keyring(cookie) + if success: + logger.info("Cookie stored securely in keyring") + else: + logger.warning("Could not store cookie in system keyring") + return success + else: + logger.info("Keyring disabled, cookie not stored") + return False + + +def clear_authentication() -> bool: + """ + Clear stored authentication. + + Returns: + bool: True if clearing was successful, False otherwise + """ + config = get_config() + + if config.linkedin.use_keyring: + success = clear_cookie_from_keyring() + if success: + logger.info("Authentication cleared from keyring") + else: + logger.warning("Could not clear authentication from keyring") + return success + else: + logger.info("Keyring disabled, nothing to clear") + return True + + +def validate_cookie_format(cookie: str) -> bool: + """ + Validate that the cookie has the expected format. + + Args: + cookie: Cookie string to validate + + Returns: + bool: True if cookie format is valid, False otherwise + """ + if not cookie: + return False + + # LinkedIn session cookies typically start with "li_at=" + if cookie.startswith("li_at=") and len(cookie) > MIN_COOKIE_LENGTH: + return True + + # Also accept raw cookie values (without li_at= prefix) + if ( + not cookie.startswith("li_at=") + and len(cookie) > MIN_RAW_COOKIE_LENGTH + and "=" not in cookie + ): + return True + + return False + + +def ensure_authentication() -> str: + """ + Ensure authentication is available, raising clear error if not. + + Returns: + str: LinkedIn session cookie + + Raises: + CredentialsNotFoundError: If no authentication is available with clear instructions + """ + try: + return get_authentication() + except CredentialsNotFoundError: + config = get_config() + + if config.chrome.non_interactive: + raise CredentialsNotFoundError( + "No LinkedIn cookie found. Please provide cookie via " + "environment variable (LINKEDIN_COOKIE) or run with --get-cookie to obtain one." + ) + else: + raise CredentialsNotFoundError( + "No LinkedIn authentication found. Please run setup to configure authentication." + ) diff --git a/linkedin_mcp_server/config/__init__.py b/linkedin_mcp_server/config/__init__.py index caa6151..a79d7ee 100644 --- a/linkedin_mcp_server/config/__init__.py +++ b/linkedin_mcp_server/config/__init__.py @@ -1,14 +1,15 @@ # src/linkedin_mcp_server/config/__init__.py -from typing import Optional import logging -from .schema import AppConfig, ChromeConfig, LinkedInConfig, ServerConfig +from typing import Optional + from .loaders import load_config from .providers import ( - get_credentials_from_keyring, - save_credentials_to_keyring, clear_credentials_from_keyring, + get_credentials_from_keyring, get_keyring_name, + save_credentials_to_keyring, ) +from .schema import AppConfig, ChromeConfig, LinkedInConfig, ServerConfig logger = logging.getLogger(__name__) diff --git a/linkedin_mcp_server/config/loaders.py b/linkedin_mcp_server/config/loaders.py index 87735cc..2143ae7 100644 --- a/linkedin_mcp_server/config/loaders.py +++ b/linkedin_mcp_server/config/loaders.py @@ -1,10 +1,11 @@ # src/linkedin_mcp_server/config/loaders.py -import os import argparse import logging +import os from typing import Optional -from .schema import AppConfig + from .providers import get_chromedriver_paths +from .schema import AppConfig logger = logging.getLogger(__name__) diff --git a/linkedin_mcp_server/config/secrets.py b/linkedin_mcp_server/config/secrets.py index 8857e40..a5071aa 100644 --- a/linkedin_mcp_server/config/secrets.py +++ b/linkedin_mcp_server/config/secrets.py @@ -111,13 +111,13 @@ def prompt_for_authentication() -> str: def setup_cookie_from_login() -> str: """Login with credentials and capture cookie.""" - from linkedin_mcp_server.drivers.chrome import setup_driver_for_cookie_capture + from linkedin_mcp_server.setup import capture_cookie_from_credentials print("šŸ”‘ LinkedIn login required to obtain cookie") credentials = prompt_for_credentials() - # Use special driver setup for cookie capture - cookie = setup_driver_for_cookie_capture( + # Use existing cookie capture functionality + cookie = capture_cookie_from_credentials( credentials["email"], credentials["password"] ) diff --git a/linkedin_mcp_server/drivers/chrome.py b/linkedin_mcp_server/drivers/chrome.py index 2842638..7b6243c 100644 --- a/linkedin_mcp_server/drivers/chrome.py +++ b/linkedin_mcp_server/drivers/chrome.py @@ -1,16 +1,15 @@ -# src/linkedin_mcp_server/drivers/chrome.py +# linkedin_mcp_server/drivers/chrome.py """ Chrome driver management for LinkedIn scraping. This module handles the creation and management of Chrome WebDriver instances. +Simplified to focus only on driver management without authentication setup. """ import logging import os -import sys from typing import Dict, Optional -import inquirer # type: ignore from linkedin_scraper.exceptions import ( CaptchaRequiredError, InvalidCredentialsError, @@ -25,19 +24,10 @@ from selenium.webdriver.chrome.service import Service from linkedin_mcp_server.config import get_config -from linkedin_mcp_server.config.providers import ( - clear_credentials_from_keyring, - clear_cookie_from_keyring, -) -from linkedin_mcp_server.config.secrets import ( - get_authentication, - get_credentials, - has_authentication, -) -from linkedin_mcp_server.exceptions import ( - CredentialsNotFoundError, - DriverInitializationError, -) +from linkedin_mcp_server.exceptions import DriverInitializationError + +# Constants +DEFAULT_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36" # Global driver storage to reuse sessions active_drivers: Dict[str, webdriver.Chrome] = {} @@ -45,23 +35,17 @@ logger = logging.getLogger(__name__) -def get_or_create_driver() -> Optional[webdriver.Chrome]: +def create_chrome_driver() -> webdriver.Chrome: """ - Get existing driver or create a new one using the configured settings. + Create a new Chrome WebDriver instance with proper configuration. Returns: - Optional[webdriver.Chrome]: Chrome WebDriver instance or None if initialization fails - in non-interactive mode + webdriver.Chrome: Configured Chrome WebDriver instance Raises: - WebDriverException: If the driver cannot be created and not in non-interactive mode + WebDriverException: If driver creation fails """ config = get_config() - session_id = "default" # We use a single session for simplicity - - # Return existing driver if available - if session_id in active_drivers: - return active_drivers[session_id] # Set up Chrome options chrome_options = Options() @@ -71,85 +55,47 @@ def get_or_create_driver() -> Optional[webdriver.Chrome]: if config.chrome.headless: chrome_options.add_argument("--headless=new") - # Add essential options for stability (compatible with both Grid and direct) + # Add essential options for stability chrome_options.add_argument("--no-sandbox") chrome_options.add_argument("--disable-dev-shm-usage") chrome_options.add_argument("--disable-gpu") chrome_options.add_argument("--window-size=1920,1080") chrome_options.add_argument("--disable-extensions") chrome_options.add_argument("--disable-background-timer-throttling") - chrome_options.add_argument( - "--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36" - ) + + # Set user agent (configurable with sensible default) + user_agent = getattr(config.chrome, "user_agent", DEFAULT_USER_AGENT) + chrome_options.add_argument(f"--user-agent={user_agent}") # Add any custom browser arguments from config for arg in config.chrome.browser_args: chrome_options.add_argument(arg) # Initialize Chrome driver - try: - logger.info("Initializing Chrome WebDriver...") + logger.info("Initializing Chrome WebDriver...") - # Use ChromeDriver path from environment or config - chromedriver_path = ( - os.environ.get("CHROMEDRIVER_PATH") or config.chrome.chromedriver_path - ) + # Use ChromeDriver path from environment or config + chromedriver_path = ( + os.environ.get("CHROMEDRIVER_PATH") or config.chrome.chromedriver_path + ) - if chromedriver_path: - logger.info(f"Using ChromeDriver at path: {chromedriver_path}") - service = Service(executable_path=chromedriver_path) - driver = webdriver.Chrome(service=service, options=chrome_options) - else: - logger.info("Using auto-detected ChromeDriver") - driver = webdriver.Chrome(options=chrome_options) + if chromedriver_path: + logger.info(f"Using ChromeDriver at path: {chromedriver_path}") + service = Service(executable_path=chromedriver_path) + driver = webdriver.Chrome(service=service, options=chrome_options) + else: + logger.info("Using auto-detected ChromeDriver") + driver = webdriver.Chrome(options=chrome_options) - logger.info("Chrome WebDriver initialized successfully") + logger.info("Chrome WebDriver initialized successfully") - # Add a page load timeout for safety - driver.set_page_load_timeout(60) + # Add a page load timeout for safety + driver.set_page_load_timeout(60) - # Try to log in with retry loop - max_retries = 3 - for attempt in range(max_retries): - try: - if login_to_linkedin(driver): - logger.info("Successfully logged in to LinkedIn") - active_drivers[session_id] = driver - return driver - except ( - CaptchaRequiredError, - InvalidCredentialsError, - SecurityChallengeError, - TwoFactorAuthError, - RateLimitError, - LoginTimeoutError, - CredentialsNotFoundError, - ) as e: - if config.chrome.non_interactive: - # In non-interactive mode, propagate the error - driver.quit() - raise e - else: - # In interactive mode, handle the error and potentially retry - should_retry = handle_login_error(e) - if should_retry and attempt < max_retries - 1: - logger.info(f"Retry attempt {attempt + 2}/{max_retries}") - continue - else: - # Clean up driver on final failure - driver.quit() - return None - except Exception as e: - error_msg = f"Error creating web driver: {e}" - logger.error( - error_msg, - extra={"exception_type": type(e).__name__, "exception_message": str(e)}, - ) + # Set shorter implicit wait for faster cookie validation + driver.implicitly_wait(10) - if config.chrome.non_interactive: - raise DriverInitializationError(error_msg) - else: - raise WebDriverException(error_msg) + return driver def login_with_cookie(driver: webdriver.Chrome, cookie: str) -> bool: @@ -166,301 +112,122 @@ def login_with_cookie(driver: webdriver.Chrome, cookie: str) -> bool: try: from linkedin_scraper import actions # type: ignore - # Use linkedin-scraper cookie login + logger.info("Attempting cookie authentication...") + + # Set shorter timeout for faster failure detection + driver.set_page_load_timeout(15) + actions.login(driver, cookie=cookie) - # Verify login by checking current URL + # Quick check - if we're on login page, cookie is invalid current_url = driver.current_url - if ( + if "login" in current_url or "uas/login" in current_url: + logger.warning("Cookie authentication failed - redirected to login page") + return False + elif ( "feed" in current_url or "mynetwork" in current_url or "linkedin.com/in/" in current_url ): + logger.info("Cookie authentication successful") return True else: + logger.warning("Cookie authentication failed - unexpected page") return False + except Exception as e: logger.warning(f"Cookie authentication failed: {e}") return False - - -def capture_session_cookie(driver: webdriver.Chrome) -> Optional[str]: - """ - Capture LinkedIn session cookie from driver. - - Args: - driver: Chrome WebDriver instance - - Returns: - Optional[str]: Session cookie if found, None otherwise - """ - try: - # Get li_at cookie which is the main LinkedIn session cookie - cookie = driver.get_cookie("li_at") - if cookie and cookie.get("value"): - return f"li_at={cookie['value']}" - return None - except Exception as e: - logger.warning(f"Failed to capture session cookie: {e}") - return None - - -def setup_driver_for_cookie_capture(email: str, password: str) -> Optional[str]: - """ - Setup a temporary driver to login and capture cookie. - - Args: - email: LinkedIn email - password: LinkedIn password - - Returns: - Optional[str]: Captured cookie if successful, None otherwise - """ - config = get_config() - - # Set up Chrome options for cookie capture - chrome_options = Options() - if config.chrome.headless: - chrome_options.add_argument("--headless=new") - - # Add essential options - chrome_options.add_argument("--no-sandbox") - chrome_options.add_argument("--disable-dev-shm-usage") - chrome_options.add_argument("--disable-gpu") - chrome_options.add_argument("--window-size=1920,1080") - - try: - # Create temporary driver - driver = webdriver.Chrome(options=chrome_options) + finally: + # Restore normal timeout driver.set_page_load_timeout(60) - # Login using linkedin-scraper - from linkedin_scraper import actions # type: ignore - - actions.login( - driver, - email, - password, - interactive=not config.chrome.non_interactive, - ) - - # Capture cookie - cookie = capture_session_cookie(driver) - - # Clean up - driver.quit() - - return cookie - - except Exception as e: - logger.error(f"Failed to capture cookie: {e}") - if "driver" in locals(): - driver.quit() - return None - -def login_to_linkedin(driver: webdriver.Chrome) -> bool: +def login_to_linkedin(driver: webdriver.Chrome, authentication: str) -> None: """ - Log in to LinkedIn using cookie-first authentication. + Log in to LinkedIn using provided authentication. Args: driver: Chrome WebDriver instance - - Returns: - bool: True if login was successful, False otherwise + authentication: LinkedIn session cookie Raises: - Various login-related errors from linkedin-scraper + Various login-related errors from linkedin-scraper or this module """ - config = get_config() - - # Try cookie authentication first - try: - cookie = get_authentication() - if login_with_cookie(driver, cookie): - logger.info("Successfully logged in to LinkedIn using cookie") - return True - else: - # Cookie login failed - clear invalid cookie from keyring - logger.warning( - "Cookie authentication failed - cookie may be expired or invalid" - ) - clear_cookie_from_keyring() - except CredentialsNotFoundError: - # No cookie available, fall back to credentials - pass - except Exception as e: - logger.warning(f"Cookie authentication failed: {e}") - # Clear invalid cookie from keyring - clear_cookie_from_keyring() - - # Fallback to credential-based login - try: - credentials = get_credentials() - except CredentialsNotFoundError as e: - if config.chrome.non_interactive: - raise e - # Only prompt if not in non-interactive mode - from linkedin_mcp_server.config.secrets import prompt_for_credentials - - credentials = prompt_for_credentials() + # Try cookie authentication + if login_with_cookie(driver, authentication): + logger.info("Successfully logged in to LinkedIn using cookie") + return - if not credentials: - raise CredentialsNotFoundError("No authentication method available") + # If we get here, cookie authentication failed + logger.error("Cookie authentication failed") - # Login to LinkedIn using enhanced linkedin-scraper - logger.info("Logging in to LinkedIn with credentials...") + # Clear invalid cookie from keyring + from linkedin_mcp_server.authentication import clear_authentication - from linkedin_scraper import actions # type: ignore + clear_authentication() + logger.info("Cleared invalid cookie from authentication storage") - # Use linkedin-scraper login but with simplified error handling + # Check current page to determine the issue try: - actions.login( - driver, - credentials["email"], - credentials["password"], - interactive=not config.chrome.non_interactive, - ) - - logger.info("Successfully logged in to LinkedIn") - - # Capture cookie for future use - cookie = capture_session_cookie(driver) - if cookie: - from linkedin_mcp_server.config.providers import save_cookie_to_keyring - - save_cookie_to_keyring(cookie) - logger.info("Session cookie captured and stored") - - return True - - except Exception: - # Check current page to determine the real issue - current_url = driver.current_url + current_url: str = driver.current_url if "checkpoint/challenge" in current_url: - # We're on a challenge page - this is the real issue, not credentials if "security check" in driver.page_source.lower(): raise SecurityChallengeError( challenge_url=current_url, message="LinkedIn requires a security challenge. Please complete it manually and restart the application.", ) else: - raise CaptchaRequiredError( - captcha_url=current_url, - ) - - elif "feed" in current_url or "mynetwork" in current_url: - # Actually logged in successfully despite the exception - logger.info("Successfully logged in to LinkedIn") - - # Capture cookie for future use - cookie = capture_session_cookie(driver) - if cookie: - from linkedin_mcp_server.config.providers import save_cookie_to_keyring - - save_cookie_to_keyring(cookie) - logger.info("Session cookie captured and stored") - - return True - + raise CaptchaRequiredError(captcha_url=current_url) else: - # Check for actual credential issues - page_source = driver.page_source.lower() - if any( - pattern in page_source - for pattern in ["wrong email", "wrong password", "incorrect", "invalid"] - ): - raise InvalidCredentialsError("Invalid LinkedIn email or password.") - elif "too many" in page_source: - raise RateLimitError( - "Too many login attempts. Please wait and try again later." - ) - else: - raise LoginTimeoutError( - "Login failed. Please check your credentials and network connection." - ) + raise InvalidCredentialsError( + "Cookie authentication failed - cookie may be expired or invalid" + ) + except Exception as e: + # If we can't determine the specific error, raise a generic one + raise LoginTimeoutError(f"Login failed: {str(e)}") -def handle_login_error(error: Exception) -> bool: - """Handle login errors in interactive mode. - Returns: - bool: True if user wants to retry, False if they want to exit +def get_or_create_driver(authentication: str) -> webdriver.Chrome: """ - config = get_config() + Get existing driver or create a new one and login. - logger.error(f"\nāŒ {str(error)}") + Args: + authentication: LinkedIn session cookie for login - if config.chrome.headless: - logger.info( - "šŸ” Try running with visible browser window: uv run main.py --no-headless" - ) - - # Only allow retry for credential errors - if isinstance(error, InvalidCredentialsError): - retry = inquirer.prompt( - [ - inquirer.Confirm( - "retry", - message="Would you like to try with different credentials?", - default=True, - ), - ] - ) - if retry and retry.get("retry", False): - clear_credentials_from_keyring() - logger.info("āœ… Credentials cleared from keyring.") - logger.info("šŸ”„ Retrying with new credentials...") - return True + Returns: + webdriver.Chrome: Chrome WebDriver instance, logged in and ready - return False + Raises: + DriverInitializationError: If driver creation fails + Various login-related errors: If login fails + """ + session_id = "default" # We use a single session for simplicity + # Return existing driver if available + if session_id in active_drivers: + logger.info("Using existing Chrome WebDriver session") + return active_drivers[session_id] -def initialize_driver() -> None: - """ - Initialize the driver based on global configuration. - """ - config = get_config() + try: + # Create new driver + driver = create_chrome_driver() - if config.server.lazy_init: - logger.info( - "Using lazy initialization - driver will be created on first tool call" - ) - if has_authentication(): - logger.info("LinkedIn authentication found in configuration") - else: - logger.info("No LinkedIn authentication found - will set up on first use") - return + # Login to LinkedIn + login_to_linkedin(driver, authentication) - # Pre-check authentication availability to trigger setup if needed - if not config.chrome.non_interactive and not has_authentication(): - # In interactive mode without authentication, trigger setup first - logger.info("Setting up LinkedIn authentication...") - try: - get_authentication() # This will trigger the interactive setup - except CredentialsNotFoundError: - pass # Setup was cancelled or failed, continue to driver creation - - # Validate chromedriver can be found - if config.chrome.chromedriver_path: - logger.info(f"āœ… ChromeDriver found at: {config.chrome.chromedriver_path}") - os.environ["CHROMEDRIVER"] = config.chrome.chromedriver_path - else: - logger.info("āš ļø ChromeDriver not found in common locations.") - logger.info("⚔ Continuing with automatic detection...") - logger.info( - "šŸ’” Tip: install ChromeDriver and set the CHROMEDRIVER environment variable" - ) + # Store successful driver + active_drivers[session_id] = driver + logger.info("Chrome WebDriver session created and authenticated successfully") - # Create driver and log in - try: - driver = get_or_create_driver() - if driver: - logger.info("āœ… Web driver initialized successfully") - else: - # Driver creation failed - always raise an error - raise DriverInitializationError("Failed to initialize web driver") + return driver + + except WebDriverException as e: + error_msg = f"Error creating web driver: {e}" + logger.error(error_msg) + raise DriverInitializationError(error_msg) except ( CaptchaRequiredError, InvalidCredentialsError, @@ -468,83 +235,56 @@ def initialize_driver() -> None: TwoFactorAuthError, RateLimitError, LoginTimeoutError, - CredentialsNotFoundError, ) as e: - # Always re-raise login-related errors so main.py can handle them + # Login-related errors - clean up driver if it was created + if session_id in active_drivers: + active_drivers[session_id].quit() + del active_drivers[session_id] raise e - except WebDriverException as e: - if config.chrome.non_interactive: - raise DriverInitializationError( - f"Failed to initialize web driver: {str(e)}" - ) - logger.error(f"āŒ Failed to initialize web driver: {str(e)}") - handle_driver_error() -def handle_driver_error() -> None: +def close_all_drivers() -> None: + """Close all active drivers and clean up resources.""" + global active_drivers + + for session_id, driver in active_drivers.items(): + try: + logger.info(f"Closing Chrome WebDriver session: {session_id}") + driver.quit() + except Exception as e: + logger.warning(f"Error closing driver {session_id}: {e}") + + active_drivers.clear() + logger.info("All Chrome WebDriver sessions closed") + + +def get_active_driver() -> Optional[webdriver.Chrome]: """ - Handle ChromeDriver initialization errors by providing helpful options. + Get the currently active driver without creating a new one. + + Returns: + Optional[webdriver.Chrome]: Active driver if available, None otherwise """ - config = get_config() + session_id = "default" + return active_drivers.get(session_id) - # Skip interactive handling in non-interactive mode - if config.chrome.non_interactive: - logger.error( - "āŒ ChromeDriver is required for this application to work properly." - ) - sys.exit(1) - - questions = [ - inquirer.List( - "chromedriver_action", - message="What would you like to do?", - choices=[ - ("Specify ChromeDriver path manually", "specify"), - ("Get help installing ChromeDriver", "help"), - ("Exit", "exit"), - ], - ), - ] - answers = inquirer.prompt(questions) - - if answers["chromedriver_action"] == "specify": - path = inquirer.prompt( - [inquirer.Text("custom_path", message="Enter ChromeDriver path")] - )["custom_path"] - - if os.path.exists(path): - # Update config with the new path - config.chrome.chromedriver_path = path - os.environ["CHROMEDRIVER"] = path - logger.info(f"āœ… ChromeDriver path set to: {path}") - logger.info( - "šŸ’” Please restart the application to use the new ChromeDriver path." - ) - logger.info(" Example: uv run main.py") - sys.exit(0) - else: - logger.warning(f"āš ļø Warning: The specified path does not exist: {path}") - logger.info("šŸ’” Please check the path and restart the application.") - sys.exit(1) - - elif answers["chromedriver_action"] == "help": - logger.info("\nšŸ“‹ ChromeDriver Installation Guide:") - logger.info( - "1. Find your Chrome version: Chrome menu > Help > About Google Chrome" - ) - logger.info( - "2. Download matching ChromeDriver: https://chromedriver.chromium.org/downloads" - ) - logger.info("3. Place ChromeDriver in a location on your PATH") - logger.info(" - macOS/Linux: /usr/local/bin/ is recommended") - logger.info( - " - Windows: Add to a directory in your PATH or specify the full path\n" - ) - - if inquirer.prompt( - [inquirer.Confirm("try_again", message="Try again?", default=True)] - )["try_again"]: - initialize_driver() - - logger.error("āŒ ChromeDriver is required for this application to work properly.") - sys.exit(1) + +def capture_session_cookie(driver: webdriver.Chrome) -> Optional[str]: + """ + Capture LinkedIn session cookie from driver. + + Args: + driver: Chrome WebDriver instance + + Returns: + Optional[str]: Session cookie if found, None otherwise + """ + try: + # Get li_at cookie which is the main LinkedIn session cookie + cookie = driver.get_cookie("li_at") + if cookie and cookie.get("value"): + return f"li_at={cookie['value']}" + return None + except Exception as e: + logger.warning(f"Failed to capture session cookie: {e}") + return None diff --git a/linkedin_mcp_server/error_handler.py b/linkedin_mcp_server/error_handler.py index 1e9d3d2..745f306 100644 --- a/linkedin_mcp_server/error_handler.py +++ b/linkedin_mcp_server/error_handler.py @@ -68,9 +68,9 @@ def convert_exception_to_response( """ if isinstance(exception, CredentialsNotFoundError): return { - "error": "credentials_not_found", + "error": "authentication_not_found", "message": str(exception), - "resolution": "Provide LinkedIn credentials via environment variables", + "resolution": "Provide LinkedIn cookie via LINKEDIN_COOKIE environment variable or run setup", } elif isinstance(exception, InvalidCredentialsError): @@ -161,17 +161,18 @@ def safe_get_driver(): Safely get or create a driver with proper error handling. Returns: - Driver instance or None if initialization fails + Driver instance Raises: - LinkedInMCPError: If driver initialization fails in non-interactive mode + LinkedInMCPError: If driver initialization fails """ + from linkedin_mcp_server.authentication import ensure_authentication from linkedin_mcp_server.drivers.chrome import get_or_create_driver - driver = get_or_create_driver() - if not driver: - from linkedin_mcp_server.exceptions import DriverInitializationError + # Get authentication first + authentication = ensure_authentication() - raise DriverInitializationError("Failed to initialize Chrome driver") + # Create driver with authentication + driver = get_or_create_driver(authentication) return driver diff --git a/linkedin_mcp_server/logging_config.py b/linkedin_mcp_server/logging_config.py index 98616ed..c5871a2 100644 --- a/linkedin_mcp_server/logging_config.py +++ b/linkedin_mcp_server/logging_config.py @@ -84,7 +84,8 @@ def configure_logging(debug: bool = False, json_format: bool = False) -> None: debug: Whether to enable debug logging json_format: Whether to use JSON formatting for logs """ - log_level = logging.DEBUG if debug else logging.INFO + # Set end-user appropriate logging level: WARNING for production, DEBUG for debug mode + log_level = logging.DEBUG if debug else logging.WARNING if json_format: formatter = MCPJSONFormatter() @@ -104,6 +105,7 @@ def configure_logging(debug: bool = False, json_format: bool = False) -> None: console_handler.setFormatter(formatter) root_logger.addHandler(console_handler) - # Set specific loggers - logging.getLogger("selenium").setLevel(logging.WARNING) - logging.getLogger("urllib3").setLevel(logging.WARNING) + # Set specific loggers to reduce noise + logging.getLogger("selenium").setLevel(logging.ERROR) + logging.getLogger("urllib3").setLevel(logging.ERROR) + logging.getLogger("urllib3.connectionpool").setLevel(logging.ERROR) diff --git a/linkedin_mcp_server/setup.py b/linkedin_mcp_server/setup.py new file mode 100644 index 0000000..5208cb1 --- /dev/null +++ b/linkedin_mcp_server/setup.py @@ -0,0 +1,337 @@ +# linkedin_mcp_server/setup.py +""" +Interactive setup module for LinkedIn MCP Server. + +This module handles interactive setup flows and authentication configuration. +""" + +import logging +import os +from contextlib import contextmanager +from typing import Dict, Iterator + +import inquirer +from selenium import webdriver +from selenium.webdriver.chrome.options import Options + +from linkedin_mcp_server.authentication import store_authentication +from linkedin_mcp_server.config import get_config +from linkedin_mcp_server.config.providers import ( + get_credentials_from_keyring, + save_credentials_to_keyring, +) +from linkedin_mcp_server.config.schema import AppConfig +from linkedin_mcp_server.exceptions import CredentialsNotFoundError + +logger = logging.getLogger(__name__) + + +def get_credentials_for_setup() -> Dict[str, str]: + """ + Get LinkedIn credentials for setup purposes. + + Returns: + Dict[str, str]: Dictionary with email and password + + Raises: + CredentialsNotFoundError: If credentials cannot be obtained + """ + config = get_config() + + # First, try configuration (includes environment variables) + if config.linkedin.email and config.linkedin.password: + logger.info("Using LinkedIn credentials from configuration") + return {"email": config.linkedin.email, "password": config.linkedin.password} + + # Second, try keyring if enabled + if config.linkedin.use_keyring: + credentials = get_credentials_from_keyring() + if credentials["email"] and credentials["password"]: + logger.info("Using LinkedIn credentials from keyring") + return {"email": credentials["email"], "password": credentials["password"]} + + # If in non-interactive mode and no credentials found, raise error + if config.chrome.non_interactive: + raise CredentialsNotFoundError( + "No LinkedIn credentials found. Please provide credentials via " + "environment variables (LINKEDIN_EMAIL, LINKEDIN_PASSWORD) for setup." + ) + + # Otherwise, prompt for credentials + return prompt_for_credentials() + + +def prompt_for_credentials() -> Dict[str, str]: + """ + Prompt user for LinkedIn credentials. + + Returns: + Dict[str, str]: Dictionary with email and password + + Raises: + KeyboardInterrupt: If user cancels input + """ + config: AppConfig = get_config() + + print("šŸ”‘ LinkedIn credentials required for setup") + questions = [ + inquirer.Text("email", message="LinkedIn Email"), + inquirer.Password("password", message="LinkedIn Password"), + ] + credentials: dict[str, str] = inquirer.prompt(questions) + + if not credentials: + raise KeyboardInterrupt("Credential input was cancelled") + + # Store credentials securely in keyring if enabled + if config.linkedin.use_keyring: + if save_credentials_to_keyring(credentials["email"], credentials["password"]): + logger.info("Credentials stored securely in keyring") + else: + logger.warning("Could not store credentials in system keyring") + + return credentials + + +@contextmanager +def temporary_chrome_driver() -> Iterator[webdriver.Chrome]: + """ + Context manager for creating temporary Chrome driver with automatic cleanup. + + Yields: + webdriver.Chrome: Configured Chrome WebDriver instance + + Raises: + Exception: If driver creation fails + """ + config: AppConfig = get_config() + + logger.info("Creating temporary browser for cookie capture...") + + # Set up Chrome options for cookie capture + chrome_options = Options() + if config.chrome.headless: + chrome_options.add_argument("--headless=new") + + # Add essential options + # chrome_options.add_argument("--no-sandbox") + # chrome_options.add_argument("--disable-dev-shm-usage") + # chrome_options.add_argument("--disable-gpu") + # chrome_options.add_argument("--window-size=3456,2234") + + driver = None + try: + # Create temporary driver + chromedriver_path = ( + os.environ.get("CHROMEDRIVER_PATH") or config.chrome.chromedriver_path + ) + + if chromedriver_path: + from selenium.webdriver.chrome.service import Service + + service = Service(executable_path=chromedriver_path) + driver = webdriver.Chrome(service=service, options=chrome_options) + else: + driver = webdriver.Chrome(options=chrome_options) + + driver.set_page_load_timeout(60) + yield driver + + finally: + if driver: + driver.quit() + + +def capture_cookie_from_credentials(email: str, password: str) -> str: + """ + Login with credentials and capture session cookie using temporary driver. + + Args: + email: LinkedIn email + password: LinkedIn password + + Returns: + str: Captured session cookie + + Raises: + Exception: If login or cookie capture fails + """ + with temporary_chrome_driver() as driver: + # Login using linkedin-scraper + from linkedin_scraper import actions + + config: AppConfig = get_config() + interactive: bool = not config.chrome.non_interactive + logger.info(f"Logging in to LinkedIn... Interactive: {interactive}") + actions.login( + driver, + email, + password, + timeout=60, # longer timeout for login (captcha, mobile verification, etc.) + interactive=interactive, # Respect configuration setting + ) + + # Capture cookie + cookie_obj: dict[str, str] = driver.get_cookie("li_at") + if cookie_obj and cookie_obj.get("value"): + cookie: str = cookie_obj["value"] + logger.info("Successfully captured session cookie") + return cookie + else: + raise Exception("Failed to capture session cookie from browser") + + +def test_cookie_validity(cookie: str) -> bool: + """ + Test if a cookie is valid by attempting to use it with a temporary driver. + + Args: + cookie: LinkedIn session cookie to test + + Returns: + bool: True if cookie is valid, False otherwise + """ + try: + with temporary_chrome_driver() as driver: + from linkedin_mcp_server.drivers.chrome import login_with_cookie + + return login_with_cookie(driver, cookie) + except Exception as e: + logger.warning(f"Cookie validation failed: {e}") + return False + + +def prompt_for_cookie() -> str: + """ + Prompt user to input LinkedIn cookie directly. + + Returns: + str: LinkedIn session cookie + + Raises: + KeyboardInterrupt: If user cancels input + ValueError: If cookie format is invalid + """ + print("šŸŖ Please provide your LinkedIn session cookie") + cookie = inquirer.text("LinkedIn Cookie") + + if not cookie: + raise KeyboardInterrupt("Cookie input was cancelled") + + # Normalize cookie format + if cookie.startswith("li_at="): + cookie: str = cookie.split("li_at=")[1] + + return cookie + + +def run_interactive_setup() -> str: + """ + Run interactive setup to configure authentication. + + Returns: + str: Configured LinkedIn session cookie + + Raises: + Exception: If setup fails + """ + print("šŸ”— LinkedIn MCP Server Setup") + print("Choose how you'd like to authenticate:") + + # Ask user for setup method + setup_method = inquirer.list_input( + "Setup method", + choices=[ + ("I have a LinkedIn cookie", "cookie"), + ("Login with email/password to get cookie", "credentials"), + ], + default="cookie", + ) + + if setup_method == "cookie": + # User provides cookie directly + cookie = prompt_for_cookie() + + # Test the cookie with a temporary driver + print("šŸ” Testing provided cookie...") + if test_cookie_validity(cookie): + # Store the valid cookie + store_authentication(cookie) + logger.info("āœ… Authentication configured successfully") + return cookie + else: + print("āŒ The provided cookie is invalid or expired") + retry = inquirer.confirm( + "Would you like to try with email/password instead?", default=True + ) + if not retry: + raise Exception("Setup cancelled - invalid cookie provided") + + # Fall through to credentials flow + setup_method = "credentials" + + if setup_method == "credentials": + # Get credentials and attempt login with retry + max_retries = 3 + for attempt in range(max_retries): + try: + credentials = get_credentials_for_setup() + + print("šŸ”‘ Logging in to capture session cookie...") + cookie = capture_cookie_from_credentials( + credentials["email"], credentials["password"] + ) + + # Store the captured cookie + store_authentication(cookie) + logger.info("āœ… Authentication configured successfully") + return cookie + + except Exception as e: + logger.error(f"Login failed: {e}") + print(f"āŒ Login failed: {e}") + + if attempt < max_retries - 1: + retry = inquirer.confirm( + "Would you like to try with different credentials?", + default=True, + ) + if not retry: + break + # Clear stored credentials to prompt for new ones + from linkedin_mcp_server.config.providers import ( + clear_credentials_from_keyring, + ) + + clear_credentials_from_keyring() + else: + raise Exception(f"Setup failed after {max_retries} attempts") + + raise Exception("Setup cancelled by user") + + # This should never be reached, but ensures type checker knows all paths are covered + raise Exception("Unexpected setup flow completion") + + +def run_cookie_extraction_setup() -> str: + """ + Run setup specifically for cookie extraction (--get-cookie mode). + + Returns: + str: Captured LinkedIn session cookie for display + + Raises: + Exception: If setup fails + """ + logger.info("šŸ”— LinkedIn MCP Server - Cookie Extraction mode started") + print("šŸ”— LinkedIn MCP Server - Cookie Extraction") + + # Get credentials + credentials: dict[str, str] = get_credentials_for_setup() + + # Capture cookie + cookie: str = capture_cookie_from_credentials( + credentials["email"], credentials["password"] + ) + + return cookie diff --git a/main.py b/main.py index 747eadc..eda54fb 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,11 @@ # main.py """ LinkedIn MCP Server - A Model Context Protocol server for LinkedIn integration. + +Clean architecture with clear phase separation: +1. Authentication Setup Phase +2. Driver Management Phase +3. Server Runtime Phase """ import logging @@ -17,14 +22,17 @@ TwoFactorAuthError, ) +from linkedin_mcp_server.authentication import ( + ensure_authentication, + has_authentication, +) from linkedin_mcp_server.cli import print_claude_config - -# Import the new centralized configuration from linkedin_mcp_server.config import get_config -from linkedin_mcp_server.drivers.chrome import initialize_driver -from linkedin_mcp_server.exceptions import LinkedInMCPError +from linkedin_mcp_server.drivers.chrome import close_all_drivers, get_or_create_driver +from linkedin_mcp_server.exceptions import CredentialsNotFoundError, LinkedInMCPError from linkedin_mcp_server.logging_config import configure_logging from linkedin_mcp_server.server import create_mcp_server, shutdown_handler +from linkedin_mcp_server.setup import run_cookie_extraction_setup, run_interactive_setup logger = logging.getLogger(__name__) @@ -43,147 +51,272 @@ def choose_transport_interactive() -> Literal["stdio", "streamable-http"]: ) ] answers = inquirer.prompt(questions) + + if not answers: + raise KeyboardInterrupt("Transport selection cancelled by user") + return answers["transport"] def get_cookie_and_exit() -> None: """Get LinkedIn cookie and exit (for Docker setup).""" - print("šŸ”— LinkedIn MCP Server - Cookie Extraction šŸ”—") - print("=" * 50) - config = get_config() - # Configure logging + # Configure logging - prioritize debug mode over non_interactive configure_logging( debug=config.server.debug, - json_format=config.chrome.non_interactive, + json_format=config.chrome.non_interactive and not config.server.debug, ) - try: - from linkedin_mcp_server.config.secrets import get_credentials - from linkedin_mcp_server.drivers.chrome import setup_driver_for_cookie_capture - - # Get credentials - credentials = get_credentials() - - print("šŸ”‘ Logging in to LinkedIn...") - cookie = setup_driver_for_cookie_capture( - credentials["email"], credentials["password"] - ) + logger.info("LinkedIn MCP Server - Cookie Extraction mode started") - if cookie: - print("āœ… Login successful!") - print(f"šŸŖ LinkedIn Cookie: {cookie}") + try: + # Run cookie extraction setup + cookie = run_cookie_extraction_setup() - # Try to copy to clipboard - try: - import pyperclip + logger.info("Cookie extraction successful") + print("āœ… Login successful!") + print(f"šŸŖ LinkedIn Cookie: {cookie}") - pyperclip.copy(cookie) - print("šŸ“‹ Cookie copied to clipboard!") - except Exception as e: - logger.warning(f"Could not copy to clipboard: {e}") - print("āš ļø Copy the cookie above manually") + # Try to copy to clipboard + try: + import pyperclip - print("\nšŸ“ Usage:") - print("1. Copy the cookie above") - print("2. Set LINKEDIN_COOKIE environment variable in your Docker setup") - print("3. Or paste into Claude Desktop configuration") + pyperclip.copy(cookie) + print("šŸ“‹ Cookie copied to clipboard!") + except Exception as e: + logger.warning(f"Could not copy to clipboard: {e}") + print("āš ļø Copy the cookie above manually") - else: - print("āŒ Failed to obtain cookie") - sys.exit(1) + print("\nšŸ“ Usage:") + print("1. Copy the cookie above") + print("2. Set LINKEDIN_COOKIE environment variable in your Docker setup") + print("3. Or paste into Claude Desktop configuration") except Exception as e: logger.error(f"Error getting cookie: {e}") - print(f"āŒ Error getting cookie: {e}") + + # Provide specific guidance for security challenges + error_msg = str(e).lower() + if "security challenge" in error_msg or "captcha" in error_msg: + print("āŒ LinkedIn security challenge detected") + print("šŸ’” Try one of these solutions:") + print( + " 1. Use an existing LinkedIn cookie instead (see instructions below)" + ) + print(" 2. Login to LinkedIn in your browser first, then retry") + print( + " 3. Use --no-headless to see and complete the security challenge manually" + ) + print("\nšŸŖ To get your LinkedIn cookie manually:") + print(" 1. Login to LinkedIn in your browser") + print(" 2. Open Developer Tools (F12)") + print(" 3. Go to Application/Storage > Cookies > linkedin.com") + print(" 4. Copy the 'li_at' cookie value") + print(" 5. Set LINKEDIN_COOKIE environment variable or use --cookie flag") + elif "invalid credentials" in error_msg: + print("āŒ Invalid LinkedIn credentials") + print("šŸ’” Please check your email and password") + else: + print("āŒ Failed to obtain cookie - check your credentials") sys.exit(1) sys.exit(0) +def ensure_authentication_ready() -> str: + """ + Phase 1: Ensure authentication is ready before any drivers are created. + + Returns: + str: Valid LinkedIn session cookie + + Raises: + CredentialsNotFoundError: If authentication setup fails + """ + config = get_config() + + # Check if authentication already exists + if has_authentication(): + try: + return ensure_authentication() + except CredentialsNotFoundError: + # Authentication exists but might be invalid, continue to setup + pass + + # If in non-interactive mode and no auth, fail immediately + if config.chrome.non_interactive: + raise CredentialsNotFoundError( + "No LinkedIn authentication found. Please provide cookie via " + "environment variable (LINKEDIN_COOKIE) or run with --get-cookie to obtain one." + ) + + # Run interactive setup + logger.info("Setting up LinkedIn authentication...") + return run_interactive_setup() + + +def initialize_driver_with_auth(authentication: str) -> None: + """ + Phase 2: Initialize driver using existing authentication. + + Args: + authentication: LinkedIn session cookie + + Raises: + Various exceptions if driver creation or login fails + """ + config = get_config() + + if config.server.lazy_init: + logger.info( + "Using lazy initialization - driver will be created on first tool call" + ) + return + + logger.info("Initializing Chrome WebDriver and logging in...") + + try: + # Create driver and login with provided authentication + get_or_create_driver(authentication) + logger.info("āœ… Web driver initialized and authenticated successfully") + + except Exception as e: + logger.error(f"Failed to initialize driver: {e}") + raise e + + def main() -> None: - """Initialize and run the LinkedIn MCP server.""" + """Main application entry point with clear phase separation.""" + logger.info("šŸ”— LinkedIn MCP Server šŸ”—") print("šŸ”— LinkedIn MCP Server šŸ”—") print("=" * 40) - # Get configuration using the new centralized system + # Get configuration config = get_config() - # Handle --get-cookie flag + # Handle --get-cookie flag immediately if config.server.get_cookie: get_cookie_and_exit() - # Configure logging + # Configure logging - prioritize debug mode over non_interactive configure_logging( debug=config.server.debug, - json_format=config.chrome.non_interactive, # Use JSON format in non-interactive mode + json_format=config.chrome.non_interactive and not config.server.debug, ) logger.debug(f"Server configuration: {config}") - # Initialize the driver with configuration (initialize driver checks for lazy init options) + # Phase 1: Ensure Authentication is Ready try: - initialize_driver() + authentication = ensure_authentication_ready() + print("āœ… Authentication ready") + logger.info("Authentication ready") + except CredentialsNotFoundError as e: + logger.error(f"Authentication setup failed: {e}") + print( + "\nāŒ Authentication required - please provide LinkedIn cookie or credentials" + ) + sys.exit(1) + except KeyboardInterrupt: + print("\n\nšŸ‘‹ Setup cancelled by user") + sys.exit(0) + except Exception as e: + logger.error(f"Unexpected error during authentication setup: {e}") + print("\nāŒ Setup failed - please try again") + sys.exit(1) + + # Phase 2: Initialize Driver (if not lazy) + try: + initialize_driver_with_auth(authentication) + except InvalidCredentialsError as e: + logger.error(f"Driver initialization failed with invalid credentials: {e}") + + # Cookie was already cleared in driver layer + # In interactive mode, try setup again + if not config.chrome.non_interactive and config.server.setup: + print(f"\nāŒ {str(e)}") + print("šŸ”„ Starting interactive setup for new authentication...") + try: + new_authentication = run_interactive_setup() + # Try again with new authentication + initialize_driver_with_auth(new_authentication) + logger.info("āœ… Successfully authenticated with new credentials") + except Exception as setup_error: + logger.error(f"Setup failed: {setup_error}") + print(f"\nāŒ Setup failed: {setup_error}") + sys.exit(1) + else: + print(f"\nāŒ {str(e)}") + if not config.server.lazy_init: + sys.exit(1) except ( LinkedInMCPError, CaptchaRequiredError, - InvalidCredentialsError, SecurityChallengeError, TwoFactorAuthError, RateLimitError, LoginTimeoutError, ) as e: - logger.error( - f"Failed to initialize driver: {str(e)}", - extra={"error_type": type(e).__name__, "error_details": str(e)}, - ) - - # Always terminate if login fails and we're not using lazy initialization + logger.error(f"Driver initialization failed: {e}") + print(f"\nāŒ {str(e)}") if not config.server.lazy_init: - print(f"\nāŒ {str(e)}") - sys.exit(1) - - # In lazy init mode with non-interactive, still exit on error - if config.chrome.non_interactive: sys.exit(1) - else: - print(f"\nāŒ Error: {str(e)}") - print("šŸ’” Tip: Check your credentials and try again.") + except Exception as e: + logger.error(f"Unexpected error during driver initialization: {e}") + print(f"\nāŒ Driver initialization failed: {e}") + if not config.server.lazy_init: sys.exit(1) - # Decide transport - transport = config.server.transport - if config.server.setup: - transport = choose_transport_interactive() - - # Print configuration for Claude if in setup mode and using stdio transport - if config.server.setup and transport == "stdio": - print_claude_config() - - # Create and run the MCP server - mcp = create_mcp_server() + # Phase 3: Server Runtime + try: + # Decide transport + transport = config.server.transport + if config.server.setup: + print("\nšŸš€ Server ready! Choose transport mode:") + transport = choose_transport_interactive() + + # Print configuration for Claude if in setup mode and using stdio transport + if config.server.setup and transport == "stdio": + print_claude_config() + + # Create and run the MCP server + mcp = create_mcp_server() + + # Start server + print(f"\nšŸš€ Running LinkedIn MCP server ({transport.upper()} mode)...") + if transport == "streamable-http": + print( + f"šŸ“” HTTP server will be available at http://{config.server.host}:{config.server.port}{config.server.path}" + ) + mcp.run( + transport=transport, + host=config.server.host, + port=config.server.port, + path=config.server.path, + ) + else: + mcp.run(transport=transport) - # Start server - print(f"\nšŸš€ Running LinkedIn MCP server ({transport.upper()} mode)...") - if transport == "streamable-http": - print( - f"šŸ“” HTTP server will be available at http://{config.server.host}:{config.server.port}{config.server.path}" - ) - mcp.run( - transport=transport, - host=config.server.host, - port=config.server.port, - path=config.server.path, - ) - else: - mcp.run(transport=transport) + except KeyboardInterrupt: + print("\n\nšŸ‘‹ Server stopped by user") + exit_gracefully(0) + except Exception as e: + logger.error(f"Server runtime error: {e}") + print(f"\nāŒ Server error: {e}") + exit_gracefully(1) def exit_gracefully(exit_code: int = 0) -> None: """Exit the application gracefully, cleaning up resources.""" print("\nšŸ‘‹ Shutting down LinkedIn MCP server...") + + # Clean up drivers + close_all_drivers() + + # Clean up server shutdown_handler() + sys.exit(exit_code) diff --git a/uv.lock b/uv.lock index 527cc63..6a1638f 100644 --- a/uv.lock +++ b/uv.lock @@ -702,7 +702,7 @@ dev = [ [[package]] name = "linkedin-scraper" version = "2.11.5" -source = { git = "https://github.com/stickerdaniel/linkedin_scraper.git#1d6ff82f8b0950b060529b12102a674cfabad1bb" } +source = { git = "https://github.com/stickerdaniel/linkedin_scraper.git#30f448df90af834bafb7d9e4caebfd0032605163" } dependencies = [ { name = "lxml" }, { name = "python-dotenv" }, From 79ca250bfa1f1c7c1e1639b602ea834ae15c22c1 Mon Sep 17 00:00:00 2001 From: Daniel Sticker Date: Sun, 6 Jul 2025 05:17:25 -0400 Subject: [PATCH 3/9] fix(cookie): improve cookie extraction messages --- linkedin_mcp_server/config/loaders.py | 2 -- main.py | 17 +++++++---------- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/linkedin_mcp_server/config/loaders.py b/linkedin_mcp_server/config/loaders.py index 2143ae7..771d963 100644 --- a/linkedin_mcp_server/config/loaders.py +++ b/linkedin_mcp_server/config/loaders.py @@ -175,8 +175,6 @@ def load_from_args(config: AppConfig) -> AppConfig: if hasattr(args, "get_cookie") and args.get_cookie: config.server.get_cookie = True - config.chrome.non_interactive = True - if args.cookie: config.linkedin.cookie = args.cookie diff --git a/main.py b/main.py index eda54fb..10a32ef 100644 --- a/main.py +++ b/main.py @@ -76,23 +76,21 @@ def get_cookie_and_exit() -> None: logger.info("Cookie extraction successful") print("āœ… Login successful!") - print(f"šŸŖ LinkedIn Cookie: {cookie}") + print("šŸŖ LinkedIn Cookie extracted:") + print(cookie) # Try to copy to clipboard try: import pyperclip pyperclip.copy(cookie) - print("šŸ“‹ Cookie copied to clipboard!") + print( + "šŸ“‹ Cookie copied to clipboard! Now you can set the LINKEDIN_COOKIE environment variable in your configuration" + ) except Exception as e: logger.warning(f"Could not copy to clipboard: {e}") print("āš ļø Copy the cookie above manually") - print("\nšŸ“ Usage:") - print("1. Copy the cookie above") - print("2. Set LINKEDIN_COOKIE environment variable in your Docker setup") - print("3. Or paste into Claude Desktop configuration") - except Exception as e: logger.error(f"Error getting cookie: {e}") @@ -102,11 +100,10 @@ def get_cookie_and_exit() -> None: print("āŒ LinkedIn security challenge detected") print("šŸ’” Try one of these solutions:") print( - " 1. Use an existing LinkedIn cookie instead (see instructions below)" + " 1. Use an existing LinkedIn cookie from your browser instead (see instructions below)" ) - print(" 2. Login to LinkedIn in your browser first, then retry") print( - " 3. Use --no-headless to see and complete the security challenge manually" + " 2. Use --no-headless flag (manual installation required, does not work with Docker) and solve the security challenge manually" ) print("\nšŸŖ To get your LinkedIn cookie manually:") print(" 1. Login to LinkedIn in your browser") From 0ccdd5ec9f760d7e0794514cbbc120cb55741ee6 Mon Sep 17 00:00:00 2001 From: Daniel Sticker Date: Sun, 6 Jul 2025 05:21:43 -0400 Subject: [PATCH 4/9] chore(vscode): add task to pack DXT package --- .vscode/tasks.json | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 4c61307..c541f61 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -133,5 +133,22 @@ }, "problemMatcher": [] }, + { + "label": "bunx @anthropic-ai/dxt pack", + "detail": "Pack the DXT package", + "type": "shell", + "command": "bunx", + "args": ["@anthropic-ai/dxt", "pack"], + "group": { + "kind": "build", + "isDefault": false + }, + "presentation": { + "reveal": "always", + "panel": "new", + "focus": true + }, + "problemMatcher": [] + } ] } From a27f5d0bc106fe28d53b4c4c26b6816dce6081a0 Mon Sep 17 00:00:00 2001 From: Daniel Sticker Date: Sun, 6 Jul 2025 12:07:38 -0400 Subject: [PATCH 5/9] fix(manifest): update LinkedIn cookie description --- manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index 0311c43..69e74f4 100644 --- a/manifest.json +++ b/manifest.json @@ -85,7 +85,7 @@ "user_config": { "linkedin_cookie": { "title": "LinkedIn Cookie", - "description": "LinkedIn session cookie. Run 'docker run -it --rm -e LINKEDIN_EMAIL=your@email.com -e LINKEDIN_PASSWORD=yourpass stickerdaniel/linkedin-mcp-server --get-cookie' to obtain", + "description": "LinkedIn li_at session cookie. Follow the instructions in the README to get it.", "type": "string", "required": true, "sensitive": true From 282e77f1d979164552c0c4b725eecdcae1e5cae0 Mon Sep 17 00:00:00 2001 From: Daniel Sticker Date: Sun, 6 Jul 2025 12:53:35 -0400 Subject: [PATCH 6/9] docs(readme): enhance LinkedIn cookie retrieval instructions --- README.md | 103 ++++++++++++++++++++++++++++++++++++++++++++------ manifest.json | 2 +- 2 files changed, 92 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 6c13063..1d83e85 100644 --- a/README.md +++ b/README.md @@ -36,17 +36,16 @@ Suggest improvements for my CV to target this job posting https://www.linkedin.c > [!NOTE] > July 2025: All tools are currently functional and actively maintained. If you encounter any issues, please report them in the [GitHub issues](https://github.com/stickerdaniel/linkedin-mcp-server/issues). ---- +
+
## 🐳 Docker Setup (Recommended - Universal) **Prerequisites:** Make sure you have [Docker](https://www.docker.com/get-started/) installed and running. -**Zero setup required** - just add the mcp server to your client config and replace email and password with your linkedin credentials. - ### Installation -**Claude Desktop:** +**Client Configuration:** ```json { "mcpServers": { @@ -54,20 +53,52 @@ Suggest improvements for my CV to target this job posting https://www.linkedin.c "command": "docker", "args": [ "run", "-i", "--rm", - "-e", "LINKEDIN_EMAIL", - "-e", "LINKEDIN_PASSWORD", + "-e", "LINKEDIN_COOKIE", "stickerdaniel/linkedin-mcp-server", "--no-setup" ], "env": { - "LINKEDIN_EMAIL": "your.email@example.com", - "LINKEDIN_PASSWORD": "your_password" + "LINKEDIN_COOKIE": "XXXXXX...", } } } } ``` +### Getting the LinkedIn Cookie +
+🐳 Docker get-cookie method + +**Run the server with the `--get-cookie` flag:** +```bash +docker run -i --rm \ + -e LINKEDIN_EMAIL="your.email@example.com" \ + -e LINKEDIN_PASSWORD="your_password" \ + stickerdaniel/linkedin-mcp-server \ + --get-cookie +``` +Copy the cookie from the output and set it as `LINKEDIN_COOKIE` in your client configuration. If this fails with a captcha challenge, use the method below. +
+
+🌐 Chrome DevTools Guide + +1. Open LinkedIn and login +2. Open Chrome DevTools (F12 or right-click → Inspect) +3. Go to **Application** > **Storage** > **Cookies** > **https://www.linkedin.com** +4. Find the cookie named `li_at` +5. Copy the **Value** field (this is your LinkedIn session cookie) +6. Use this value as your `LINKEDIN_COOKIE` in the configuration + +
+
+ +> [!NOTE] +> The cookie will expire during the next 30 days. Just get the new cookie and update your config. + +> [!TIP] +> There are also many cookie manager extensions that you can use to easily get the cookie. + +### Docker Setup Help
šŸ”§ Configuration @@ -83,6 +114,8 @@ Suggest improvements for my CV to target this job posting https://www.linkedin.c - `--host HOST` - HTTP server host (default: 127.0.0.1) - `--port PORT` - HTTP server port (default: 8000) - `--path PATH` - HTTP server path (default: /mcp) +- `--get-cookie` - Attempt to login with email and password and extract the LinkedIn cookie +- `--cookie {cookie}` - Pass a specific LinkedIn cookie for login **HTTP Mode Example (for web-based MCP clients):** ```bash @@ -116,6 +149,9 @@ docker run -i --rm \ - You might get a captcha challenge if you logged in a lot of times in a short period of time, then try again later or follow the [local setup instructions](#-local-setup-develop--contribute) to run the server manually in --no-headless mode where you can debug the login process (solve captcha manually)
+
+
+ ## šŸ“¦ Claude Desktop (DXT Extension) **Prerequisites:** [Claude Desktop](https://claude.ai/download) and [Docker](https://www.docker.com/get-started/) installed @@ -126,6 +162,40 @@ docker run -i --rm \ 3. Configure your LinkedIn credentials when prompted 4. Start using LinkedIn tools immediately +### Getting the LinkedIn Cookie +
+🐳 Docker get-cookie method + +**Run the server with the `--get-cookie` flag:** +```bash +docker run -i --rm \ + -e LINKEDIN_EMAIL="your.email@example.com" \ + -e LINKEDIN_PASSWORD="your_password" \ + stickerdaniel/linkedin-mcp-server \ + --get-cookie +``` +Copy the cookie from the output and set it as `LINKEDIN_COOKIE` in your client configuration. If this fails with a captcha challenge, use the method below. +
+
+🌐 Chrome DevTools Guide + +1. Open LinkedIn and login +2. Open Chrome DevTools (F12 or right-click → Inspect) +3. Go to **Application** > **Storage** > **Cookies** > **https://www.linkedin.com** +4. Find the cookie named `li_at` +5. Copy the **Value** field (this is your LinkedIn session cookie) +6. Use this value as your `LINKEDIN_COOKIE` in the configuration + +
+
+ +> [!NOTE] +> The cookie will expire during the next 30 days. Just get the new cookie and update your config. + +> [!TIP] +> There are also many cookie manager extensions that you can use to easily get the cookie. + +### DXT Extension Setup Help
ā— Troubleshooting @@ -139,6 +209,9 @@ docker run -i --rm \ - You might get a captcha challenge if you logged in a lot of times in a short period of time, then try again later or follow the [local setup instructions](#-local-setup-develop--contribute) to run the server manually in --no-headless mode where you can debug the login process (solve captcha manually)
+
+
+ ## šŸ Local Setup (Develop & Contribute) **Prerequisites:** [Chrome browser](https://www.google.com/chrome/) and [Git](https://git-scm.com/downloads) installed @@ -170,21 +243,25 @@ uv sync --group dev uv run pre-commit install # 5. Start the server once manually -# (you will be prompted to enter your LinkedIn credentials, and they are securely stored in your OS keychain) +# You will be prompted to enter your LinkedIn credentials, and they will be securely stored in your OS keychain +# Once logged in, your cookie will be stored in your OS keychain and used for subsequent runs until it expires uv run main.py --no-headless --no-lazy-init ``` +### Local Setup Help
šŸ”§ Configuration **CLI Options:** - `--no-headless` - Show browser window (debugging) - `--debug` - Enable detailed logging -- `--no-setup` - Skip credential prompts (make sure to set `LINKEDIN_EMAIL` and `LINKEDIN_PASSWORD` in env or or run the server once manualy, then it will be stored in your OS keychain and you can run the server without credentials) +- `--no-setup` - Skip credential prompts (make sure to set `LINKEDIN_COOKIE` or `LINKEDIN_EMAIL` and `LINKEDIN_PASSWORD` in env or that you run the server once manually, so the authentication is stored in your OS keychain and you can run the server without credentials) - `--no-lazy-init` - Login to LinkedIn immediately instead of waiting for the first tool call +- `--get-cookie` - Login with email and password and extract the LinkedIn cookie +- `--cookie {cookie}` - Pass a specific LinkedIn cookie for login **Claude Desktop:** -```json +```**json** { "mcpServers": { "linkedin": { @@ -217,7 +294,9 @@ uv run main.py --no-headless --no-lazy-init Feel free to open an [issue](https://github.com/stickerdaniel/linkedin-mcp-server/issues) or [PR](https://github.com/stickerdaniel/linkedin-mcp-server/pulls)! ---- + +
+
## License diff --git a/manifest.json b/manifest.json index 69e74f4..950a5ba 100644 --- a/manifest.json +++ b/manifest.json @@ -52,7 +52,7 @@ "properties": { "company_url": { "type": "string", - "description": "LinkedIn company URL (e.g., https://www.linkedin.com/company/company-name/)" + "description": "LinkedIn company URL (e.g., https://www.linkedin.com/company/docker/)" } }, "required": ["company_url"] From 0436424040825bc2546d7f0788322a666d0d7823 Mon Sep 17 00:00:00 2001 From: Daniel Sticker Date: Sun, 6 Jul 2025 13:03:57 -0400 Subject: [PATCH 7/9] refactor(authentication): update cookie length constants --- linkedin_mcp_server/authentication.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/linkedin_mcp_server/authentication.py b/linkedin_mcp_server/authentication.py index fb3ea82..7d54b52 100644 --- a/linkedin_mcp_server/authentication.py +++ b/linkedin_mcp_server/authentication.py @@ -9,15 +9,15 @@ from linkedin_mcp_server.config import get_config from linkedin_mcp_server.config.providers import ( + clear_cookie_from_keyring, get_cookie_from_keyring, save_cookie_to_keyring, - clear_cookie_from_keyring, ) from linkedin_mcp_server.exceptions import CredentialsNotFoundError # Constants for cookie validation -MIN_COOKIE_LENGTH = 20 -MIN_RAW_COOKIE_LENGTH = 10 +MIN_RAW_COOKIE_LENGTH = 110 +MIN_COOKIE_LENGTH = MIN_RAW_COOKIE_LENGTH + len("li_at=") logger = logging.getLogger(__name__) From 9668111b14dc4943f6ceda8878c3501987ac4da9 Mon Sep 17 00:00:00 2001 From: Daniel Sticker Date: Sun, 6 Jul 2025 13:04:01 -0400 Subject: [PATCH 8/9] refactor(setup): update type hints for credentials and cookies --- linkedin_mcp_server/config/secrets.py | 4 ++-- linkedin_mcp_server/setup.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/linkedin_mcp_server/config/secrets.py b/linkedin_mcp_server/config/secrets.py index a5071aa..25b8dd9 100644 --- a/linkedin_mcp_server/config/secrets.py +++ b/linkedin_mcp_server/config/secrets.py @@ -8,11 +8,11 @@ from linkedin_mcp_server.exceptions import CredentialsNotFoundError from .providers import ( + get_cookie_from_keyring, get_credentials_from_keyring, get_keyring_name, - save_credentials_to_keyring, - get_cookie_from_keyring, save_cookie_to_keyring, + save_credentials_to_keyring, ) logger = logging.getLogger(__name__) diff --git a/linkedin_mcp_server/setup.py b/linkedin_mcp_server/setup.py index 5208cb1..793a1fd 100644 --- a/linkedin_mcp_server/setup.py +++ b/linkedin_mcp_server/setup.py @@ -78,7 +78,7 @@ def prompt_for_credentials() -> Dict[str, str]: inquirer.Text("email", message="LinkedIn Email"), inquirer.Password("password", message="LinkedIn Password"), ] - credentials: dict[str, str] = inquirer.prompt(questions) + credentials: Dict[str, str] = inquirer.prompt(questions) if not credentials: raise KeyboardInterrupt("Credential input was cancelled") @@ -172,7 +172,7 @@ def capture_cookie_from_credentials(email: str, password: str) -> str: ) # Capture cookie - cookie_obj: dict[str, str] = driver.get_cookie("li_at") + cookie_obj: Dict[str, str] = driver.get_cookie("li_at") if cookie_obj and cookie_obj.get("value"): cookie: str = cookie_obj["value"] logger.info("Successfully captured session cookie") @@ -327,7 +327,7 @@ def run_cookie_extraction_setup() -> str: print("šŸ”— LinkedIn MCP Server - Cookie Extraction") # Get credentials - credentials: dict[str, str] = get_credentials_for_setup() + credentials: Dict[str, str] = get_credentials_for_setup() # Capture cookie cookie: str = capture_cookie_from_credentials( From ce057357028f7f80cee8d08f62da2b375b797d5c Mon Sep 17 00:00:00 2001 From: Daniel Sticker Date: Sun, 6 Jul 2025 13:08:07 -0400 Subject: [PATCH 9/9] refactor(authentication): remove legacy duplicated cookie handling functions --- README.md | 1 + linkedin_mcp_server/config/secrets.py | 89 --------------------------- 2 files changed, 1 insertion(+), 89 deletions(-) diff --git a/README.md b/README.md index 1d83e85..21cca9c 100644 --- a/README.md +++ b/README.md @@ -259,6 +259,7 @@ uv run main.py --no-headless --no-lazy-init - `--no-lazy-init` - Login to LinkedIn immediately instead of waiting for the first tool call - `--get-cookie` - Login with email and password and extract the LinkedIn cookie - `--cookie {cookie}` - Pass a specific LinkedIn cookie for login +- `--help` - Show help **Claude Desktop:** ```**json** diff --git a/linkedin_mcp_server/config/secrets.py b/linkedin_mcp_server/config/secrets.py index 25b8dd9..c01dc1b 100644 --- a/linkedin_mcp_server/config/secrets.py +++ b/linkedin_mcp_server/config/secrets.py @@ -8,60 +8,14 @@ from linkedin_mcp_server.exceptions import CredentialsNotFoundError from .providers import ( - get_cookie_from_keyring, get_credentials_from_keyring, get_keyring_name, - save_cookie_to_keyring, save_credentials_to_keyring, ) logger = logging.getLogger(__name__) -def has_authentication() -> bool: - """Check if authentication is available without triggering interactive setup.""" - config = get_config() - - # Check environment variable - if config.linkedin.cookie: - return True - - # Check keyring if enabled - if config.linkedin.use_keyring: - cookie = get_cookie_from_keyring() - if cookie: - return True - - return False - - -def get_authentication() -> str: - """Get LinkedIn cookie from keyring, environment, or interactive setup.""" - config = get_config() - - # First, try environment variable - if config.linkedin.cookie: - logger.info("Using LinkedIn cookie from environment") - return config.linkedin.cookie - - # Second, try keyring if enabled - if config.linkedin.use_keyring: - cookie = get_cookie_from_keyring() - if cookie: - logger.info(f"Using LinkedIn cookie from {get_keyring_name()}") - return cookie - - # If in non-interactive mode and no cookie found, raise error - if config.chrome.non_interactive: - raise CredentialsNotFoundError( - "No LinkedIn cookie found. Please provide cookie via " - "environment variable (LINKEDIN_COOKIE) or run with --get-cookie to obtain one." - ) - - # Otherwise, prompt for cookie or setup - return prompt_for_authentication() - - def get_credentials() -> Dict[str, str]: """Get LinkedIn credentials from config, keyring, or prompt (legacy for --get-cookie).""" config = get_config() @@ -89,49 +43,6 @@ def get_credentials() -> Dict[str, str]: return prompt_for_credentials() -def prompt_for_authentication() -> str: - """Prompt user for LinkedIn cookie or setup via login.""" - print("šŸ”— LinkedIn MCP Server Setup") - - # Ask if user has a cookie - has_cookie = inquirer.confirm("Do you have a LinkedIn cookie?", default=False) - - if has_cookie: - cookie = inquirer.text("LinkedIn Cookie", validate=lambda _, x: len(x) > 10) - if save_cookie_to_keyring(cookie): - logger.info(f"Cookie stored securely in {get_keyring_name()}") - else: - logger.warning("Could not store cookie in system keyring.") - logger.info("Your cookie will only be used for this session.") - return cookie - else: - # Login flow to get cookie - return setup_cookie_from_login() - - -def setup_cookie_from_login() -> str: - """Login with credentials and capture cookie.""" - from linkedin_mcp_server.setup import capture_cookie_from_credentials - - print("šŸ”‘ LinkedIn login required to obtain cookie") - credentials = prompt_for_credentials() - - # Use existing cookie capture functionality - cookie = capture_cookie_from_credentials( - credentials["email"], credentials["password"] - ) - - if cookie: - if save_cookie_to_keyring(cookie): - logger.info(f"Cookie stored securely in {get_keyring_name()}") - else: - logger.warning("Could not store cookie in system keyring.") - logger.info("Your cookie will only be used for this session.") - return cookie - else: - raise CredentialsNotFoundError("Failed to obtain LinkedIn cookie") - - def prompt_for_credentials() -> Dict[str, str]: """Prompt user for LinkedIn credentials and store them securely.""" print(f"šŸ”‘ LinkedIn credentials required (will be stored in {get_keyring_name()})")