131 lines
5.2 KiB
Python
131 lines
5.2 KiB
Python
#!/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
|