#!/usr/bin/env python3 """Skald launcher for the LinkedIn MCP connector. The server itself is the upstream `mcp-server-linkedin` package (pinned in requirements.txt); this file exists because a marketplace connector must ship an entry file for `mcp_config.args[0]` to name. It does two things the package cannot do for itself on a headless box: 1. Pins the paths that must live inside the connector directory. Skald re-copies a connector's shipped files on every update but never deletes anything else, so state written here survives both updates and container recreates — which the upstream defaults (`~/.linkedin-mcp`, `~/.cache/ms-playwright`) do not. 2. Materializes the session bundle from the cookie the user pasted into the configuration form (see session.py). Upstream expects that bundle to be produced by signing in through a visible browser and copied over by hand; there is no display here, so we build it from the form instead. stdout belongs to JSON-RPC. Everything this script says goes to stderr. """ import os import sys from pathlib import Path HERE = Path(__file__).resolve().parent # The package derives its auth root from the *parent* of USER_DATA_DIR, so # pointing the profile at `auth/profile` puts `cookies.json`, `source-state.json` # and the derived `runtime-profiles/` together under `auth/`. AUTH_DIR = HERE / "auth" # Chromium is ~350 MB and is NOT shipped: patchright downloads its own pinned # build on first use. Keeping it here means it survives updates instead of being # re-fetched into every fresh container. BROWSERS_DIR = HERE / "browsers" # Records which credential the current bundle was built from, so a browser # session is only rebuilt when the user actually pastes a new cookie. STAMP = AUTH_DIR / ".credential" def log(message: str) -> None: print(f"[linkedin] {message}", file=sys.stderr, flush=True) def sync_session() -> None: """Rebuild the session bundle when the configured cookie has changed. Rewriting on every start would be wasteful but harmless; the reason to guard it is that a rewrite mints a new `login_generation`, which discards the runtime profile the package built from the previous one — throwing away a warmed-up, LinkedIn-accepted session for no reason. """ from session import fingerprint, read_config, write_bundle li_at, jsessionid, user_agent = read_config() if not li_at: log("no li_at cookie configured — set it in the connector's settings") log("(copy it from your browser: DevTools > Application > Cookies > linkedin.com)") return current = fingerprint(li_at, jsessionid, user_agent) previous = STAMP.read_text().strip() if STAMP.is_file() else "" if current == previous: return write_bundle(AUTH_DIR, li_at, jsessionid, user_agent) STAMP.write_text(current + "\n") os.chmod(STAMP, 0o600) log("session bundle written from the configured cookie" if not previous else "cookie changed — session bundle rebuilt") def main() -> None: # Skald puts `.pydeps` on PYTHONPATH when it launches this connector; adding # it here too keeps the script runnable by hand for debugging. pydeps = HERE / ".pydeps" if pydeps.is_dir() and str(pydeps) not in sys.path: sys.path.insert(0, str(pydeps)) if str(HERE) not in sys.path: sys.path.insert(0, str(HERE)) # setdefault throughout: an operator overriding any of these wins. os.environ.setdefault("USER_DATA_DIR", str(AUTH_DIR / "profile")) os.environ.setdefault("PLAYWRIGHT_BROWSERS_PATH", str(BROWSERS_DIR)) # No display here. Headless also keeps the download to the headless shell # alone; full Chrome for Testing is only fetched for a headed run. os.environ.setdefault("HEADLESS", "true") os.environ.setdefault("TRANSPORT", "stdio") # Replay the pasted cookie under the user agent it was minted with, when we # know it — LinkedIn associates a session with its browser fingerprint. _, _, user_agent = __import__("session").read_config() if user_agent: os.environ.setdefault("USER_AGENT", user_agent) try: sync_session() except Exception as exc: # noqa: BLE001 - never block startup on this log(f"could not write the session bundle: {exc}") try: from linkedin_mcp_server.cli_main import main as server_main except ImportError as exc: log(f"dependencies missing ({exc}); expected mcp-server-linkedin in .pydeps") raise server_main() if __name__ == "__main__": main()