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": []
+ }
]
}
diff --git a/README.md b/README.md
index 6c13063..21cca9c 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,26 @@ 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
+- `--help` - Show help
**Claude Desktop:**
-```json
+```**json**
{
"mcpServers": {
"linkedin": {
@@ -217,7 +295,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/linkedin_mcp_server/authentication.py b/linkedin_mcp_server/authentication.py
new file mode 100644
index 0000000..7d54b52
--- /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 (
+ clear_cookie_from_keyring,
+ get_cookie_from_keyring,
+ save_cookie_to_keyring,
+)
+from linkedin_mcp_server.exceptions import CredentialsNotFoundError
+
+# Constants for cookie validation
+MIN_RAW_COOKIE_LENGTH = 110
+MIN_COOKIE_LENGTH = MIN_RAW_COOKIE_LENGTH + len("li_at=")
+
+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 ca1ea27..771d963 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__)
@@ -33,6 +34,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 +128,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 +173,11 @@ 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
+ 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..c01dc1b 100644
--- a/linkedin_mcp_server/config/secrets.py
+++ b/linkedin_mcp_server/config/secrets.py
@@ -17,7 +17,7 @@
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)
diff --git a/linkedin_mcp_server/drivers/chrome.py b/linkedin_mcp_server/drivers/chrome.py
index 3c21db2..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,12 +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
-from linkedin_mcp_server.config.secrets import get_credentials
-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] = {}
@@ -38,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()
@@ -64,244 +55,179 @@ 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_to_linkedin(driver: webdriver.Chrome) -> bool:
+def login_with_cookie(driver: webdriver.Chrome, cookie: str) -> bool:
"""
- Log in to LinkedIn using stored or provided credentials.
+ 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
- Raises:
- Various login-related errors from linkedin-scraper
+ logger.info("Attempting cookie authentication...")
+
+ # Set shorter timeout for faster failure detection
+ driver.set_page_load_timeout(15)
+
+ actions.login(driver, cookie=cookie)
+
+ # Quick check - if we're on login page, cookie is invalid
+ current_url = driver.current_url
+ 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
+ finally:
+ # Restore normal timeout
+ driver.set_page_load_timeout(60)
+
+
+def login_to_linkedin(driver: webdriver.Chrome, authentication: str) -> None:
"""
- config = get_config()
+ Log in to LinkedIn using provided authentication.
- # Get LinkedIn credentials from config
- 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
+ Args:
+ driver: Chrome WebDriver instance
+ authentication: LinkedIn session cookie
- credentials = prompt_for_credentials()
+ Raises:
+ Various login-related errors from linkedin-scraper or this module
+ """
+ # 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 credentials 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...")
+ # 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")
- 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")
- 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
+
+ Raises:
+ DriverInitializationError: If driver creation fails
+ Various login-related errors: If login fails
+ """
+ session_id = "default" # We use a single session for simplicity
- return False
+ # Return existing driver if available
+ if session_id in active_drivers:
+ logger.info("Using existing Chrome WebDriver session")
+ return active_drivers[session_id]
+ try:
+ # Create new driver
+ driver = create_chrome_driver()
-def initialize_driver() -> None:
- """
- Initialize the driver based on global configuration.
- """
- config = get_config()
+ # Login to LinkedIn
+ login_to_linkedin(driver, authentication)
- if config.server.lazy_init:
- 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")
- else:
- logger.info(
- "No LinkedIn credentials found - will look for stored credentials on first use"
- )
- return
+ # Store successful driver
+ active_drivers[session_id] = driver
+ logger.info("Chrome WebDriver session created and authenticated successfully")
- # 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"
- )
+ return driver
- # 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")
+ except WebDriverException as e:
+ error_msg = f"Error creating web driver: {e}"
+ logger.error(error_msg)
+ raise DriverInitializationError(error_msg)
except (
CaptchaRequiredError,
InvalidCredentialsError,
@@ -309,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..793a1fd
--- /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 d070325..10a32ef 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,87 +51,269 @@ 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)."""
+ config = get_config()
+
+ # Configure logging - prioritize debug mode over non_interactive
+ configure_logging(
+ debug=config.server.debug,
+ json_format=config.chrome.non_interactive and not config.server.debug,
+ )
+
+ logger.info("LinkedIn MCP Server - Cookie Extraction mode started")
+
+ try:
+ # Run cookie extraction setup
+ cookie = run_cookie_extraction_setup()
+
+ logger.info("Cookie extraction successful")
+ print("ā
Login successful!")
+ print("šŖ LinkedIn Cookie extracted:")
+ print(cookie)
+
+ # Try to copy to clipboard
+ try:
+ import pyperclip
+
+ pyperclip.copy(cookie)
+ 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")
+
+ except Exception as e:
+ logger.error(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 from your browser instead (see instructions below)"
+ )
+ print(
+ " 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")
+ 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()
- # Configure logging
+ # Handle --get-cookie flag immediately
+ if config.server.get_cookie:
+ get_cookie_and_exit()
+
+ # 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()
+ # 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()
+ # 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()
+ # 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/manifest.json b/manifest.json
index 7f49b0d..950a5ba 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"
]
}
@@ -53,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"]
@@ -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 li_at session cookie. Follow the instructions in the README to get it.",
"type": "string",
"required": true,
"sensitive": true
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" },