add linkedin connector + gitignore .claude + regenerate index

This commit is contained in:
2026-08-07 13:30:44 +01:00
parent c778450812
commit 2ad3954e3a
11 changed files with 704 additions and 1 deletions
+130
View File
@@ -0,0 +1,130 @@
#!/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