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' && ( + + )} {!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