From af63029ebd245ff6f0e2c4e580edb0c0320bb10f Mon Sep 17 00:00:00 2001
From: TheAchiever6823 <61914223+ShawnMadadha@users.noreply.github.com>
Date: Sat, 16 May 2026 22:26:20 -0700
Subject: [PATCH] [shawn] feat: add LinkedIn MCP with in-app sign-in
---
GETTING_STARTED.md | 36 ++++
electron/main.js | 237 +++++++++++++++++++++++++
electron/preload.js | 4 +
frontend/src/app/pages/Tools/Tools.tsx | 100 ++++++++++-
scripts/setup-linkedin-mcp.sh | 26 +++
5 files changed, 400 insertions(+), 3 deletions(-)
create mode 100755 scripts/setup-linkedin-mcp.sh
diff --git a/GETTING_STARTED.md b/GETTING_STARTED.md
index 1a871dc6..9cbd8fd2 100644
--- a/GETTING_STARTED.md
+++ b/GETTING_STARTED.md
@@ -204,6 +204,42 @@ To skip `npx` delegation entirely when testing: set `INSTAGRAM_MCP_NO_NPX_FALLBA
---
+## LinkedIn (`linkedin-scraper-mcp` via uvx) (optional)
+
+LinkedIn integration uses [stickerdaniel/linkedin-mcp-server](https://github.com/stickerdaniel/linkedin-mcp-server) (PyPI: `linkedin-scraper-mcp`). Auth is a persistent [Patchright](https://github.com/Kaliiiiiiiiii-Vinyzu/patchright) browser profile, not OAuth and not cookies. This means **one LinkedIn account per host**: the saved profile at `~/.linkedin-mcp/profile/` is shared across every OpenSwarm session on this machine.
+
+### Prerequisite
+
+[`uv`](https://docs.astral.sh/uv/getting-started/installation/) must be on `PATH` (provides the `uvx` runner).
+
+### Connect from the UI (recommended)
+
+1. Open the **Tools** page in the sidebar.
+2. Find the **LinkedIn** tile and click **Sign in with LinkedIn**.
+3. A Chromium window opens. Complete sign-in (2FA and captcha are supported).
+4. The window closes on success; the profile is written to `~/.linkedin-mcp/profile/` and the tile flips to **Connected**.
+
+Behind the scenes, Electron spawns `uvx linkedin-scraper-mcp@latest --login`. No credentials touch OpenSwarm; the MCP server owns the browser session.
+
+### CLI fallback (headless dev, CI, web build)
+
+If you are not using the desktop shell, run the equivalent script from the repo root:
+
+```bash
+bash scripts/setup-linkedin-mcp.sh
+```
+
+### Sessions and reset
+
+* Sessions can expire. If a tool call fails with an auth error, click **Sign in with LinkedIn** again (or rerun the script).
+* To wipe the stored profile (e.g. to switch accounts): click the **Connected** chip in the Tools page, or run `uvx linkedin-scraper-mcp@latest --logout`.
+
+### Tools exposed (17 total)
+
+`get_person_profile`, `get_my_profile`, `connect_with_person`, `get_sidebar_profiles`, `get_inbox`, `get_conversation`, `search_conversations`, `send_message`, `get_company_profile`, `get_company_posts`, `search_companies`, `get_company_employees`, `search_jobs`, `search_people`, `get_job_details`, `get_feed`, `close_session`.
+
+---
+
## Project structure
```
diff --git a/electron/main.js b/electron/main.js
index 1804d8ad..83cab64a 100644
--- a/electron/main.js
+++ b/electron/main.js
@@ -1749,3 +1749,240 @@ ipcMain.handle('instagram-logout', async (_event, _mcpEnv) => {
return { ok: false, error: err?.message || String(err) };
}
});
+
+// LinkedIn connect/logout via stickerdaniel/linkedin-mcp-server (PyPI: linkedin-scraper-mcp).
+// The MCP server reads a Patchright persistent browser profile at ~/.linkedin-mcp/profile/.
+// We open an embedded Electron BrowserWindow at linkedin.com/login (same UX as the
+// Instagram flow), poll for the li_at cookie, then inject the harvested cookies into
+// the Patchright profile via a Python helper. Cookies without an expires timestamp
+// are treated as session cookies by Chromium and wiped on context close, so we
+// stamp a 1-year default on anything missing one before injection.
+// Why path probing for uv/uvx: Electron launched from Finder on macOS inherits
+// /usr/bin:/bin only, not the user's shell PATH where uv/uvx typically lives.
+function resolveBin(name) {
+ const candidates = [
+ path.join(os.homedir(), '.local', 'bin', name),
+ `/opt/homebrew/bin/${name}`,
+ `/usr/local/bin/${name}`,
+ ];
+ for (const c of candidates) {
+ try { if (fs.existsSync(c)) return c; } catch (_) {}
+ }
+ return name;
+}
+
+const LINKEDIN_PROFILE_DIR = path.join(os.homedir(), '.linkedin-mcp', 'profile');
+
+const LINKEDIN_INJECT_SCRIPT = `
+import asyncio, json, sys, time
+from pathlib import Path
+
+async def main():
+ from patchright.async_api import async_playwright
+ raw = sys.stdin.read()
+ payload = json.loads(raw)
+ cookies = payload["cookies"]
+ profile_dir = Path(payload["profile_dir"]).expanduser()
+ profile_dir.mkdir(parents=True, exist_ok=True)
+ async with async_playwright() as p:
+ ctx = await p.chromium.launch_persistent_context(
+ user_data_dir=str(profile_dir),
+ headless=True,
+ )
+ await ctx.add_cookies(cookies)
+ await ctx.close()
+ print(json.dumps({"ok": True, "count": len(cookies)}))
+
+try:
+ asyncio.run(main())
+except Exception as exc:
+ print(json.dumps({"ok": False, "error": f"{type(exc).__name__}: {exc}"}))
+ sys.exit(1)
+`;
+
+function electronCookiesToPatchright(electronCookies) {
+ const oneYearFromNow = Math.floor(Date.now() / 1000) + 365 * 24 * 3600;
+ const sameSiteMap = {
+ 'no_restriction': 'None',
+ 'lax': 'Lax',
+ 'strict': 'Strict',
+ 'unspecified': 'Lax',
+ };
+ return electronCookies.map((c) => {
+ const expires = (typeof c.expirationDate === 'number' && c.expirationDate > 0)
+ ? Math.floor(c.expirationDate)
+ : oneYearFromNow;
+ const sameSite = sameSiteMap[c.sameSite] || 'Lax';
+ const out = {
+ name: c.name,
+ value: c.value,
+ domain: c.domain,
+ path: c.path || '/',
+ expires,
+ httpOnly: Boolean(c.httpOnly),
+ secure: Boolean(c.secure),
+ sameSite,
+ };
+ if (out.sameSite === 'None') out.secure = true;
+ return out;
+ });
+}
+
+function injectLinkedinCookies(electronCookies) {
+ return new Promise((resolve) => {
+ const uv = resolveBin('uv');
+ const cookies = electronCookiesToPatchright(electronCookies);
+ const env = { ...process.env, UV_HTTP_TIMEOUT: '300' };
+ let child;
+ try {
+ child = spawn(uv, ['run', '--with', 'patchright', '--quiet', 'python', '-c', LINKEDIN_INJECT_SCRIPT], {
+ env, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true,
+ });
+ } catch (err) {
+ resolve({ ok: false, error: `failed to spawn ${uv}: ${err.message}` });
+ return;
+ }
+ let stdout = '', stderr = '';
+ child.stdout.on('data', (c) => { stdout += c.toString(); });
+ child.stderr.on('data', (c) => { stderr += c.toString(); });
+ child.on('error', (e) => resolve({ ok: false, error: `spawn error: ${e.message}` }));
+ child.on('exit', (code) => {
+ if (code !== 0) {
+ resolve({ ok: false, error: `cookie inject exited ${code}: ${(stderr || stdout).slice(-400)}` });
+ return;
+ }
+ try {
+ const line = stdout.trim().split('\n').filter(Boolean).pop() || '{}';
+ const parsed = JSON.parse(line);
+ resolve(parsed.ok ? { ok: true, count: parsed.count } : { ok: false, error: parsed.error || 'unknown injection error' });
+ } catch (e) {
+ resolve({ ok: false, error: `inject script non-JSON output: ${stdout.slice(-200)}` });
+ }
+ });
+ try {
+ child.stdin.write(JSON.stringify({ cookies, profile_dir: LINKEDIN_PROFILE_DIR }));
+ child.stdin.end();
+ } catch (e) {
+ resolve({ ok: false, error: `stdin write failed: ${e.message}` });
+ }
+ });
+}
+
+ipcMain.handle('linkedin-connect', async (_event, payload) => {
+ const toolId = (payload && payload.toolId) || '';
+ if (!toolId) {
+ return { ok: false, error: 'linkedin-connect: missing toolId from caller' };
+ }
+
+ // Fast-path: if the persist partition already has a valid li_at from a
+ // previous session, skip the BrowserWindow entirely and inject straight in.
+ // Same UX feel as Instagram: re-Connect is near-instant when nothing expired.
+ // URL-based query (not { domain }) so we pick up cookies set on .linkedin.com,
+ // .www.linkedin.com, www.linkedin.com all in one call.
+ try {
+ const partitionCookies = await session.fromPartition('persist:linkedin-auth').cookies.get({ url: 'https://www.linkedin.com/' });
+ const cached = partitionCookies.find((c) => c.name === 'li_at');
+ if (cached) {
+ console.log(`[linkedin-connect] cached li_at found in partition (${partitionCookies.length} cookies); fast-path inject for tool ${toolId}`);
+ const result = await injectLinkedinCookies(partitionCookies);
+ if (result.ok) return { ok: true, count: result.count, fastPath: true };
+ console.log(`[linkedin-connect] fast-path inject failed, falling back to window flow: ${result.error}`);
+ }
+ } catch (err) {
+ console.log(`[linkedin-connect] fast-path probe failed: ${err.message}`);
+ }
+
+ // Hidden by default. Only shown if the user actually needs to sign in.
+ const win = new BrowserWindow({
+ width: 520,
+ height: 760,
+ title: 'Sign in to LinkedIn',
+ parent: mainWindow || undefined,
+ modal: false,
+ show: false,
+ autoHideMenuBar: true,
+ webPreferences: {
+ partition: 'persist:linkedin-auth',
+ contextIsolation: true,
+ nodeIntegration: false,
+ sandbox: true,
+ },
+ });
+ try {
+ // /feed/ redirects to /login when unauthed, but lets us reuse a cached
+ // session without bouncing through the login form when it's still valid.
+ await win.loadURL('https://www.linkedin.com/feed/');
+ } catch (err) {
+ if (!win.isDestroyed()) win.close();
+ return { ok: false, error: `Failed to load LinkedIn: ${err.message}` };
+ }
+
+ return new Promise((resolve) => {
+ let settled = false;
+ let shown = false;
+ const finish = (value) => {
+ if (settled) return;
+ settled = true;
+ clearInterval(poll);
+ clearTimeout(timer);
+ if (!win.isDestroyed()) win.close();
+ resolve(value);
+ };
+ win.on('closed', () => { if (!settled) finish({ ok: false, error: 'Sign-in window was closed' }); });
+
+ let attempts = 0;
+ const tick = async () => {
+ if (settled || win.isDestroyed()) return;
+ attempts += 1;
+ try {
+ // URL-based query: matches every cookie Chromium would send to
+ // www.linkedin.com regardless of whether the cookie domain is
+ // .linkedin.com, .www.linkedin.com, www.linkedin.com, etc.
+ const cookies = await win.webContents.session.cookies.get({ url: 'https://www.linkedin.com/' });
+ const liAt = cookies.find((c) => c.name === 'li_at');
+ if (attempts <= 3 || attempts % 10 === 0) {
+ console.log(`[linkedin-connect] tick ${attempts}: ${cookies.length} cookies [${cookies.map(c => c.name).join(',')}], li_at=${liAt ? 'YES' : 'no'}`);
+ }
+ if (liAt) {
+ clearInterval(poll);
+ console.log(`[linkedin-connect] li_at acquired (${cookies.length} cookies); injecting into Patchright profile for tool ${toolId}`);
+ const result = await injectLinkedinCookies(cookies);
+ finish(result.ok ? { ok: true, count: result.count } : result);
+ return;
+ }
+ } catch (err) {
+ console.log(`[linkedin-connect] tick ${attempts}: cookie read error: ${err.message}`);
+ }
+ // No li_at after the first ~1.6s of polling means the user actually
+ // needs to sign in; reveal the window so they can.
+ if (!shown && attempts >= 2 && !win.isDestroyed()) {
+ shown = true;
+ win.show();
+ }
+ };
+
+ // Immediate first check, then 800ms cadence (faster than Instagram's
+ // 1500ms; we're optimizing for the silent-reauth case).
+ tick();
+ const poll = setInterval(tick, 800);
+ const timer = setTimeout(() => {
+ finish({ ok: false, error: 'LinkedIn sign-in timed out after 10 minutes' });
+ }, 10 * 60 * 1000);
+ });
+});
+
+ipcMain.handle('linkedin-logout', async () => {
+ return new Promise((resolve) => {
+ try {
+ if (fs.existsSync(LINKEDIN_PROFILE_DIR)) {
+ fs.rmSync(LINKEDIN_PROFILE_DIR, { recursive: true, force: true });
+ }
+ session.fromPartition('persist:linkedin-auth').clearStorageData().then(
+ () => resolve({ ok: true }),
+ (err) => resolve({ ok: false, error: `cleared profile but failed to clear partition: ${err.message}` }),
+ );
+ } catch (err) {
+ resolve({ ok: false, error: err.message || String(err) });
+ }
+ });
+});
diff --git a/electron/preload.js b/electron/preload.js
index da1a3909..2c6da6dc 100644
--- a/electron/preload.js
+++ b/electron/preload.js
@@ -32,6 +32,10 @@ const { contextBridge, ipcRenderer } = require('electron');
instagramConnect: (mcpEnv) => ipcRenderer.invoke('instagram-connect', mcpEnv),
// instagramUpgrade: (payload) => ipcRenderer.invoke('instagram-upgrade-session', payload), // disabled until trusted-notification polling is implemented
instagramLogout: (mcpEnv) => ipcRenderer.invoke('instagram-logout', mcpEnv),
+ /** Spawns `uvx linkedin-scraper-mcp@latest --login`; the server opens its own Chromium for sign-in. */
+ linkedinConnect: (payload) => ipcRenderer.invoke('linkedin-connect', payload),
+ /** Spawns `uvx linkedin-scraper-mcp@latest --logout` to wipe the Patchright profile. */
+ linkedinLogout: () => ipcRenderer.invoke('linkedin-logout'),
sendCdpCommand: (wcId, method, params) => ipcRenderer.invoke('send-cdp-command', wcId, method, params),
cdpCacheSet: (wcId, indexMap) => ipcRenderer.invoke('cdp-cache-set', wcId, indexMap),
cdpCacheGet: (wcId) => ipcRenderer.invoke('cdp-cache-get', wcId),
diff --git a/frontend/src/app/pages/Tools/Tools.tsx b/frontend/src/app/pages/Tools/Tools.tsx
index 64e01369..73aeef3a 100644
--- a/frontend/src/app/pages/Tools/Tools.tsx
+++ b/frontend/src/app/pages/Tools/Tools.tsx
@@ -309,6 +309,28 @@ const INTEGRATIONS: Integration[] = [
),
authType: 'oauth2',
},
+ {
+ id: 'linkedin',
+ name: 'LinkedIn',
+ description:
+ 'LinkedIn profiles, companies, jobs, messaging, and feed. 17 tools from stickerdaniel/linkedin-mcp-server. Auth is a Patchright persistent browser profile (one LinkedIn account per host); run scripts/setup-linkedin-mcp.sh once to sign in.',
+ mcp_config: {
+ type: 'stdio',
+ command: 'uvx',
+ args: ['linkedin-scraper-mcp@latest'],
+ env: { UV_HTTP_TIMEOUT: '300' },
+ },
+ color: '#0A66C2',
+ website: 'https://github.com/stickerdaniel/linkedin-mcp-server',
+ connectLabel: 'Sign in with LinkedIn',
+ connectInstructions: 'Click Sign in to open a Chromium window for LinkedIn login (2FA and captcha supported). The session profile is saved at ~/.linkedin-mcp/profile/ and reused on every server start. Click the Connected chip to wipe the profile. Requires `uv` installed on this machine (https://docs.astral.sh/uv/getting-started/installation/).',
+ icon: (
+
+ ),
+ },
];
const CATEGORY_ORDER = ['filesystem', 'system', 'search', 'interaction', 'agents', 'planning', 'scheduling'];
@@ -616,7 +638,7 @@ const Tools: React.FC = () => {
// browse the long tail.
const CURATED_MCP_NAMES = useMemo(() => new Set([
'google-workspace', 'microsoft-365', 'slack', 'discord',
- 'notion', 'airtable', 'hubspot', 'reddit', 'youtube', 'instagram',
+ 'notion', 'airtable', 'hubspot', 'reddit', 'youtube', 'instagram', 'linkedin',
]), []);
const regServers = useMemo(() => {
if (regSource !== 'curated') return regServersRaw;
@@ -661,6 +683,9 @@ const Tools: React.FC = () => {
/** Instagram desktop CLI (Electron); null when idle */
const [instagramConnectBusy, setInstagramConnectBusy] = useState(null);
+ /** LinkedIn desktop CLI (Electron); null when idle */
+ const [linkedinConnectBusy, setLinkedinConnectBusy] = useState(null);
+
// Full-auth upgrade dialog (password + 2FA) is disabled while we figure out
// Instagram's trusted-notification polling endpoint. Connect uses browser-only
// sign-in, which unlocks ~8 read tools. See handleInstagramConnect below.
@@ -1120,6 +1145,62 @@ const Tools: React.FC = () => {
dispatch(discoverTools(toolId));
};
+ const handleLinkedInConnect = async (toolId: string) => {
+ // Spawns `uvx linkedin-scraper-mcp@latest --login` via Electron IPC.
+ // The MCP server opens its own Patchright-driven Chromium for the user
+ // to sign in (2FA + captcha handled by LinkedIn's own UI). On exit 0,
+ // the profile at ~/.linkedin-mcp/profile/ is saved and reused by every
+ // subsequent server start. No credentials touch OpenSwarm.
+ const bridge = (window as unknown as { openswarm?: { linkedinConnect?: (arg?: { toolId: string }) => Promise<{ ok?: boolean; error?: string }> } }).openswarm?.linkedinConnect;
+ if (!bridge) {
+ setSnackbar({
+ open: true,
+ message: 'LinkedIn sign-in here needs the OpenSwarm desktop app.',
+ severity: 'error',
+ });
+ return;
+ }
+ setLinkedinConnectBusy(toolId);
+ try {
+ const result = await bridge({ toolId });
+ if (!result?.ok) {
+ setSnackbar({ open: true, message: result?.error || 'LinkedIn sign-in failed', severity: 'error' });
+ return;
+ }
+ await dispatch(updateTool({ id: toolId, auth_status: 'connected' }));
+ await dispatch(fetchToolStatus(toolId));
+ setSnackbar({ open: true, message: 'LinkedIn connected' });
+ setExpandedToolId(toolId);
+ dispatch(discoverTools(toolId));
+ } catch (err: unknown) {
+ const msg = err instanceof Error ? err.message : String(err);
+ setSnackbar({ open: true, message: msg || 'LinkedIn sign-in failed', severity: 'error' });
+ } finally {
+ setLinkedinConnectBusy(null);
+ }
+ };
+
+ const handleLinkedInDisconnect = async (toolId: string) => {
+ const bridge = (window as unknown as { openswarm?: { linkedinLogout?: () => Promise<{ ok?: boolean; error?: string }> } }).openswarm?.linkedinLogout;
+ if (!bridge) {
+ setSnackbar({ open: true, message: 'LinkedIn disconnect needs the OpenSwarm desktop app.', severity: 'error' });
+ return;
+ }
+ try {
+ const result = await bridge();
+ if (!result?.ok) {
+ setSnackbar({ open: true, message: result?.error || 'LinkedIn logout failed', severity: 'error' });
+ return;
+ }
+ await dispatch(updateTool({ id: toolId, auth_status: 'disconnected', connected_account_email: '' }));
+ await dispatch(fetchToolStatus(toolId));
+ setSnackbar({ open: true, message: 'LinkedIn disconnected' });
+ } catch (err: unknown) {
+ const msg = err instanceof Error ? err.message : String(err);
+ setSnackbar({ open: true, message: msg || 'LinkedIn logout failed', severity: 'error' });
+ }
+ };
+
const handleInstagramConnect = async (toolId: string) => {
// Single Instagram tile pointed at trypeggy/instagram_dm_mcp.
// Click Connect → in-app browser opens → user signs in (handles their own
@@ -2054,6 +2135,18 @@ const Tools: React.FC = () => {
Sign in with Instagram
)}
+ {!isDisabled && ig?.id === 'linkedin' && tool.auth_status !== 'connected' && (
+ : }
+ onClick={(e) => { e.stopPropagation(); void handleLinkedInConnect(tool.id); }}
+ disabled={linkedinConnectBusy === tool.id}
+ sx={{ borderColor: `${ig.color}40`, color: ig.color, '&:hover': { borderColor: ig.color, bgcolor: `${ig.color}10` }, textTransform: 'none', fontSize: '0.78rem', borderRadius: 1.5, py: 0.5, flexShrink: 0 }}
+ >
+ Sign in with LinkedIn
+
+ )}
{!isDisabled && ig?.credentialFields && ig?.id !== 'instagram' && tool.auth_status !== 'connected' && (
)}
{!isDisabled && ig && tool.auth_status === 'connected' && (
-
+
}
label={tool.connected_account_email ? `Connected · ${tool.connected_account_email}` : 'Connected'}
size="small"
- onDelete={(ig.credentialFields || ig.authType === 'oauth2' || ig.authType === 'device_code') ? (e: React.SyntheticEvent) => {
+ onDelete={(ig.credentialFields || ig.authType === 'oauth2' || ig.authType === 'device_code' || ig.id === 'linkedin') ? (e: React.SyntheticEvent) => {
e.stopPropagation();
if (ig.authType === 'device_code') handleM365Disconnect(tool.id);
+ else if (ig.id === 'linkedin') void handleLinkedInDisconnect(tool.id);
else handleDisconnectIntegration(tool.id, ig);
} : undefined}
onClick={(e) => e.stopPropagation()}
diff --git a/scripts/setup-linkedin-mcp.sh b/scripts/setup-linkedin-mcp.sh
new file mode 100755
index 00000000..5cd5701d
--- /dev/null
+++ b/scripts/setup-linkedin-mcp.sh
@@ -0,0 +1,26 @@
+#!/usr/bin/env bash
+# One-time interactive setup for the LinkedIn MCP server
+# (stickerdaniel/linkedin-mcp-server, PyPI: linkedin-scraper-mcp).
+#
+# Why this needs a script: auth is a persistent Patchright browser profile.
+# The --login flag opens a real Chromium window so you (the human) can sign in,
+# handle 2FA / captcha, and save state to ~/.linkedin-mcp/profile/. The MCP
+# server then reuses that profile on every start, headlessly.
+#
+# Re-run when sessions expire. To wipe the profile, run:
+# uvx linkedin-scraper-mcp@latest --logout
+
+set -euo pipefail
+
+if ! command -v uvx >/dev/null 2>&1; then
+ echo "error: uvx not found on PATH." >&2
+ echo "Install uv first: https://docs.astral.sh/uv/getting-started/installation/" >&2
+ exit 1
+fi
+
+UV_HTTP_TIMEOUT="${UV_HTTP_TIMEOUT:-300}"
+export UV_HTTP_TIMEOUT
+
+echo "Opening Chromium for LinkedIn login. Complete sign-in (and 2FA if prompted)."
+echo "Profile will be saved to ~/.linkedin-mcp/profile/"
+exec uvx linkedin-scraper-mcp@latest --login