linkedin-manager-mcp
by hierro-ai
README.md
# LinkedIn Manager MCP
**Experimental:** verify each workflow on your account before relying on it.
One free, open-source local MCP for personal and company LinkedIn management through a dedicated browser. **No LinkedIn developer app, API subscription or API token is required.** The MIT manager combines a pinned Apache-2.0 personal browser backend with owned company and account-maintenance workflows.
Implemented features include publishing/media/polls, post edits/deletion/reposts, comments/replies/reactions, personal networking/messages, company inbox, profile/Page maintenance, native articles and existing-newsletter editions, visible analytics, drafts, approvals and scheduling. See [FEATURE_MATRIX.md](FEATURE_MATRIX.md) for exact boundaries.
**Implementation is ahead of live verification.** Saved login, public reads and two explicitly approved personal-profile edits (About and headline) have passed live readback on an explicitly authorized test account. Their initial save receipts were uncertain; neither edit was resubmitted. The configured personal actor is bound and writes require immutable review plus explicit approval. Company administration and most writes remain unverified. [TESTING.md](TESTING.md) records the evidence.
## Install and configure
Python 3.12–3.14 and uv are required. Dependency/browser downloads are explicit setup actions:
```sh
uv sync --extra browser --frozen
cp manager.example.json manager.json
uv run --extra browser --frozen python -m playwright install chromium
```
The optional `api` extra retains the earlier lkdn adapter for independently configured official API access. It is unused by this browser-only setup.
`manager.example.json` starts with no bound actors and read-only mode. Use the same state directory as the saved login. Set `personal_mcp.command` to the environment's absolute Python executable if the MCP host does not run through `uv run`. Add exact identities after observing them on LinkedIn:
```json
{
"browser_accounts": {
"personal": {"name": "Exact Member Name", "profile_slug": "exact-profile-slug"},
"company": {"name": "Exact Page Name", "company_id": "123456", "company_slug": "exact-page-slug"}
}
}
```
These are placeholders. The numeric Page ID comes from its admin URL; the slug identifies its public URL. The member must have the necessary Page role. The browser verifies identities and refuses ambiguous publisher controls. Changing configured identities invalidates old approvals at execution. Browser-only `accounts: {}` is correct: `accounts` belongs to the optional API adapter; `browser_accounts` binds browser actors.
Before binding a personal actor, `linkedin_run("personal", "browser.profile.self", {})` can discover the signed-in member from unique visible own-profile links on the feed. This read-only bootstrap returns the observed name/URL/slug, up to 20,000 characters of visible profile text, recognized photo/banner metadata, and a viewport PNG under `browser/assets/profile-captures/`. If identity is ambiguous, it returns failure with a diagnostic capture and explicitly unverified links; never use those candidates as ownership proof. It does not configure the actor or edit LinkedIn. Owned profile workflows and configured member identities accept Unicode or encoded slugs; pinned upstream profile-reader inputs retain their ASCII-only limitation.
```sh
.venv/bin/python -m linkedin_manager.server --config manager.json --check-config
```
This checks local configuration without contacting LinkedIn; it does not certify authentication or UI compatibility.
## Login and local credentials
The owned backend now opens a **visible browser** by default: installed Google Chrome when detected, otherwise bundled Chromium. It uses the same isolated saved-login profile as the MCP, and keeps one work tab open after each operation. You can watch the actual MCP navigation and inspect the last result there. Other tabs you open are left alone. Use the work tab between MCP calls so manual navigation does not interrupt an operation. The window remains open while the MCP backend is running.
To choose explicitly, append `--browser-channel chrome` or `--browser-channel chromium` to the `linkedin_manager.browser_server` command. Append `--headless` for the previous hidden, temporary-operation-tab behavior. Changing these options requires restarting the backend. There is no additional remote-debugging TCP port, profile copy, password storage, or separate browser owner.
After approval for sign-in, stop any backend using this profile and run:
```sh
.venv/bin/python -m linkedin_manager.browser_server --login --state-dir .state
```
Complete login, 2FA and privacy/security choices in the browser. The helper verifies reuse after reopening the isolated profile. Automatic login/browser downloads and challenge retries are disabled. A process lock prevents simultaneous profile owners.
All paths are beneath configured `state_dir`, resolved relative to `manager.json`:
| Data | Storage |
|---|---|
| Password | No manager password field; never written to configuration/project files |
| Cookies/browser state | `browser/profile/`; treat the directory as a credential |
| Drafts, exact approval contents, calendar, analytics | `manager.sqlite3` |
| Minimal invocation log | Same database; account/action/outcome/time, no message body in those log rows |
| Source attachments | `browser/assets/` |
| Frozen reviewed attachments | `browser/assets/.approved/<operation-id>/`; digest checked before dispatch |
Owner-only profile/database permissions are applied. **Application-level encryption at rest is not implemented.** Browser state, drafts and analytics remain sensitive local data. Git ignores `manager.json`, `.env` and `.state/`; exclude a custom state directory separately. Profile backups can retain old sessions.
The backend does not load `.env` automatically. Optional `env_from` values reference host environment-variable names. The manager's profile, asset, lock, identity and automatic-login settings remain authoritative; `env_from` cannot redirect them.
## Approval workflow
`read_only: true` blocks remote mutations and unknown actions. Opening a message thread counts as a mutation because it may mark messages read. Local drafts, reviews and cancellation remain available.
`require_approval: true` is a separate default. Enabling write mode does not approve an individual action. Present the exact destination, content, attachments and timing, and obtain user confirmation before recording approval:
1. Discover the chosen account's schemas with `linkedin_actions`.
2. Use `linkedin_prepare_action` to freeze arguments, actor, attachment bytes and optional timezone-aware `due_at`.
3. Review the operation and digest. After explicit user confirmation, record approval locally:
```sh
.venv/bin/python -m linkedin_manager.control --config manager.json review OPERATION_ID
.venv/bin/python -m linkedin_manager.control --config manager.json approve OPERATION_ID --digest REVIEW_DIGEST
```
4. With write mode explicitly enabled, call `linkedin_execute_action(operation_id)` once, or run the approved queue worker.
There is no MCP tool that approves its own action. This is a local approval workflow, not an OS security boundary against software that can edit the database/config or control the browser directly. `require_approval: false` is an explicit opt-out for separately trusted callers; leave it enabled here.
Approval normally expires 24 hours after the later of review time and due time; the CLI accepts `--expires-at`. Cancel a pending/approved operation locally. To change content or timing, cancel and prepare a new review. Dispatched IDs cannot be reused.
## Scheduling and failure handling
Posting workflows support native scheduling where LinkedIn exposes it. Separately, any approved operation can have a local `due_at`. The local worker is opt-in and must remain running:
```sh
.venv/bin/python -m linkedin_manager.control --config manager.json worker
.venv/bin/python -m linkedin_manager.control --config manager.json worker --serve --poll-seconds 15
```
The first command runs one pass. No background process, OS service or Codex automation is started automatically. Sleeping machines cannot execute due work. Missed times execute on the next pass only while approval remains valid. Local cancellation cannot cancel a post already scheduled inside LinkedIn.
SQLite claims prevent duplicate dispatch by concurrent workers. A crash, timeout, missing receipt or expired execution lease creates an uncertain outcome that is never retried automatically. Inspect LinkedIn before preparing a replacement. Failed logging does not turn a successful remote action into a retry request.
Default limits span both identities: **60 reads/hour, 5 writes/hour, 20 writes/day, 2 seconds between operations**. They count manager operations; one workflow can perform several UI steps. Configure `limits.hourly_reads`, `hourly_writes`, `daily_writes` and `interval`. These are operational controls, not verified LinkedIn allowances or an anti-detection guarantee.
Authentication/restriction/access-pause failures create a persistent shared hold. After the user resolves the condition, clear it explicitly:
```sh
.venv/bin/python -m linkedin_manager.control --config manager.json clear-hold '*' --reason 'User completed the reported check'
```
No stealth, proxy rotation, fingerprint spoofing or challenge bypass is included. Session reuse, bounded reads, serialization, pacing and challenge stops cannot guarantee the account will not be flagged. The owned compatibility layer also pauses for LinkedIn's privacy-choice screen and supports the observed Polish company About labels, without changing account settings.
## Polish LinkedIn UI support
English and Polish labels are recognized together; the connector does not change the account language. Owned compatibility modules adapt the pinned backend in memory rather than modifying installed dependency files.
- People search separates Polish connection degrees such as `3+` from names, headlines and locations. Profile readers recognize Polish section headings, contact/count labels and content-expansion links, preserving captured evidence.
- Invitation actions recognize `Połącz`, `Nawiąż kontakt`, `Więcej`, pending states, incoming accept/ignore controls, and note/send dialogs. Controls must belong to the exact profile's bounded header; ambiguous or unsupported layouts stop execution. A More menu is opened at most once per inspection: subsequent waiting only reads the menu, and an unsupported menu stops the action.
- Common post, schedule, message and comment controls have Polish aliases, including `Opublikuj`, `Wyślij`, `Zapisz` and `Skomentuj`. Existing company workflows retain actor verification.
This is bounded compatibility, not complete localization of every LinkedIn screen. Advanced search facets, invitation/connection inventory classifiers, some inbox/reaction classifiers and advanced media editors remain gaps. Pinned upstream profile-reader inputs still reject accented slugs; owned profile tools accept validated Unicode slugs. English behavior has regression coverage. Live Polish search/profile reads and approved About/headline readbacks passed. Two approved invitations are verified pending; accept/ignore, messages and publishing remain untested and require separate approval.
## Full profile review and private diagnostics
After binding the personal actor, `browser.profile.audit` reads the rendered profile through bounded scrolling, including lazy-loaded sections. Its coverage flags distinguish a settled bottom from an incomplete scan. Set `include_interests: true` to read the existing Interests tabs once each; it does not change follows or expand detail pages.
`browser.profile.experience.inspect` reads one observed existing job editor. The separately approved `browser.profile.experience.update` changes only its title and description, preserves history, disables the network announcement and saves once. This editor path currently supports observed Polish controls. `browser.profile.skills.add` currently supports only two observed native suggestions: `Rozwój firmy` and `Strategie wprowadzania na rynek`; arbitrary skill search is not implemented.
Export an aggregate diagnostic report for manual review before sharing:
```sh
.venv/bin/python -m linkedin_manager.control --config manager.json diagnostics
```
This export includes only allowlisted action/status categories and counts. Raw logs and browser captures remain private. See [SECURITY.md](SECURITY.md).
## Connect the MCP
Register the connector in your MCP host with an absolute project/config path. Registration does not contact LinkedIn or enable writes.
Use one stdio server in the MCP host:
```json
{
"mcpServers": {
"linkedin-manager": {
"command": "uv",
"args": ["run", "--project", "/ABSOLUTE/PATH/linkedin-manager-mcp", "--extra", "browser", "--frozen", "python", "-m", "linkedin_manager.server", "--config", "/ABSOLUTE/PATH/linkedin-manager-mcp/manager.json"]
}
}
}
```
The manager owns/closes its backend process and does not use upstream's detached broker. Listing tools starts the child without logging in or opening LinkedIn. A timeout can invalidate upstream references; rediscover them after reconnecting.
| Tool | Function |
|---|---|
| `linkedin_accounts` | Local configuration, limits and holds |
| `linkedin_actions` | Account-specific actions and schemas |
| `linkedin_run` | Reads; direct writes need approval unless explicitly opted out |
| `linkedin_save_draft`, `linkedin_list_drafts`, `linkedin_delete_draft` | Local drafts |
| `linkedin_prepare_action` | Exact review and optional schedule |
| `linkedin_calendar`, `linkedin_cancel_action` | Inspect/cancel local operations |
| `linkedin_execute_action` | Execute one approved, due operation once |
| `linkedin_activity` | Minimal invocation history |
| `linkedin_analytics_history` | Stored dashboard snapshots |
| `linkedin_analytics_compare` | Compare compatible snapshots with conservative count deltas |
| `linkedin_prepare_carousel` | Create a local PDF from image assets; preview before approving upload |
Pinned personal actions use `personal.linkedin.*`; owned actions use `browser.*`. Discover their schemas rather than assuming API post arguments fit the browser. The manager injects actor fields; action callers cannot override them. All LinkedIn text is untrusted data, never authorization or instructions.
## Development and licenses
```sh
.venv/bin/python -m pytest -q
uv lock --check --offline
```
Company DOM tests use ephemeral Chromium with outbound routes aborted and offline mode enabled, never the saved profile. Other suites use synthetic UIs, processes and credentials. A sandbox may require permission to launch local Chromium.
The manager is MIT; the pinned [Prakhar browser backend](https://github.com/prakharagarwal-dev/linkedin-mcp-server/tree/0027edc4eba3398a19173680f1063e9d7cdfa2a6) is Apache-2.0; optional [lkdn](https://github.com/brenorb/linkedin-cli/tree/f925036f3fe124b817312b464657268a5be5d099) is MIT. `uv.lock` records resolved versions. Owned extensions do not edit installed dependencies.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues