#!/usr/bin/env python3 """Turn a pasted LinkedIn cookie into the session bundle the MCP server expects. The upstream `mcp-server-linkedin` package authenticates from a browser session, normally created by running it on a machine with a display. That is not an option on a headless box, so this connector collects the session cookie through Skald's configuration form instead and synthesizes the same bundle here. The bundle is exactly what the package calls a *source session*, the one-time bridge it uses to carry a session onto a machine that cannot log in for itself: auth/cookies.json the LinkedIn cookies, in Playwright's cookie shape auth/source-state.json which runtime minted them, and under which UA auth/profile/ the source profile directory (only its existence is checked; the real browser profile is derived per-runtime by the package on first use) Writing files is all this does — no browser is launched here, so the connector starts instantly and the (large) Chromium download stays on the first tool call. Shared by server.py (at startup) and verify.py (at activation). """ from __future__ import annotations import hashlib import json import os import time from datetime import datetime, timezone from pathlib import Path from uuid import uuid4 # LinkedIn's session cookie. Everything else in the jar is optional: the package # proves the session against /feed/ and lets the browser rebuild the rest. LI_AT = "li_at" # `source_runtime_id` marks which machine minted the session. It must NOT match # the runtime that reads it, or the package assumes the profile is native and # skips the replay that actually installs these cookies. A cookie pasted from a # desktop browser is, by definition, foreign to this container. FOREIGN_RUNTIME_ID = "browser-import-host" def _utcnow_iso() -> str: return datetime.now(timezone.utc).isoformat(timespec="seconds") def cookie_jar(li_at: str, jsessionid: str = "") -> list[dict]: """Build the cookie list. Mirrors what the package exports from a real login.""" # A year out: LinkedIn's own li_at lifetime. The value is advisory — LinkedIn # decides what is still valid — but an already-expired cookie is dropped by # the browser before it is ever sent. expires = time.time() + 365 * 24 * 3600 jar = [{ "name": LI_AT, "value": li_at, "domain": ".www.linkedin.com", "path": "/", "expires": expires, "httpOnly": True, "secure": True, "sameSite": "None", }] if jsessionid: # LinkedIn quotes this one in the header; keep the quotes if present. value = jsessionid if jsessionid.startswith('"') else f'"{jsessionid}"' jar.append({ "name": "JSESSIONID", "value": value, "domain": ".www.linkedin.com", "path": "/", "expires": expires, "httpOnly": False, "secure": True, "sameSite": "None", }) return jar def fingerprint(li_at: str, jsessionid: str, user_agent: str) -> str: """Identify the credential, so a rewrite happens only when it really changed. Hashed rather than stored: this lands in a file inside the connector directory, and the cookie is as good as a password. """ h = hashlib.sha256() for part in (li_at, jsessionid, user_agent): h.update(part.encode()) h.update(b"\0") return h.hexdigest() def write_bundle(auth_dir: Path, li_at: str, jsessionid: str = "", user_agent: str = "") -> None: """Write the session bundle into `auth_dir`, replacing any previous one.""" profile_dir = auth_dir / "profile" profile_dir.mkdir(parents=True, exist_ok=True) # `profile_exists` treats an empty directory as absent, so leave a marker. (profile_dir / ".skald-import").write_text(_utcnow_iso() + "\n") cookies_path = auth_dir / "cookies.json" cookies_path.write_text(json.dumps(cookie_jar(li_at, jsessionid), indent=2)) state = { "version": 1, "source_runtime_id": FOREIGN_RUNTIME_ID, "login_generation": str(uuid4()), "created_at": _utcnow_iso(), "profile_path": str(profile_dir), "cookies_path": str(cookies_path), # LinkedIn ties a session token to the fingerprint it was minted under, # so the runtime browser replays it under the source browser's UA when # we know it. None lets the package keep its own default. "user_agent": user_agent or None, } (auth_dir / "source-state.json").write_text(json.dumps(state, indent=2)) # The cookie is a bearer credential for the whole account. for p in (cookies_path, auth_dir / "source-state.json"): os.chmod(p, 0o600) def read_config(env: dict | None = None) -> tuple[str, str, str]: """Pull the form values out of the environment. Returns (li_at, jsessionid, ua).""" e = env if env is not None else os.environ li_at = (e.get("LINKEDIN_LI_AT") or "").strip().strip('"') jsessionid = (e.get("LINKEDIN_JSESSIONID") or "").strip() user_agent = (e.get("LINKEDIN_USER_AGENT") or "").strip() return li_at, jsessionid, user_agent