Skip to main content
Glama
Sabari2005

LinkedIn MCP Server

by Sabari2005
README.md
<p align="center">
  <img
    src="https://raw.githubusercontent.com/Sabari2005/linkedin-mcp-server/main/img.png"
    alt="LinkedIn MCP Server — power LinkedIn with AI agents. 102 tools across 15 categories, secure browser session, self-healing selectors. Reads via the Voyager API, writes via Playwright browser automation. Works with Claude, Cursor, Windsurf and GitHub Copilot."
    width="100%"
  />
</p>

# LinkedIn MCP Server

A production-grade [Model Context Protocol](https://modelcontextprotocol.io) server that
lets an AI assistant operate LinkedIn through an authenticated browser session — profile
management, posting, networking, messaging, job search and automated applications.

**102 tools across 15 categories.** No password login: authentication reuses cookies from
a browser where you are already signed in. Selectors repair themselves when LinkedIn
changes its markup — using your own model, with page content never leaving your machine.

> **Unofficial project.** Not affiliated with or endorsed by LinkedIn Corporation.
> Automating LinkedIn may violate its [User Agreement](https://www.linkedin.com/legal/user-agreement)
> and can get your account restricted. See [Responsible use](#responsible-use).

---

## Why this design

A few decisions shape everything else, and they are worth stating up front.

**Two transports, chosen per operation.** Reads prefer LinkedIn's internal Voyager API —
one HTTP request returning structured JSON, no browser. Writes go through real DOM
automation, because LinkedIn's write endpoints are the least stable part of that API and a
malformed request can silently blank a profile section. Each transport falls back to the
other.

**Selector fallback chains.** LinkedIn ships UI changes constantly and its class names are
obfuscated. Every element is described by a *ranked list* of selectors — stable test hooks
first, then ARIA roles, then visible text, then structural guesses. A markup change usually
breaks one candidate, not all four, turning a hard breakage into a silent fallback.

**Selectors that repair themselves.** When every candidate misses, the server shows your
model a redacted structural outline of the page, takes its suggestions, verifies them
against the live DOM, and remembers what works — so a markup change costs one slow call
instead of a broken tool and a wait for the next release. It is deliberately narrow: it
never relearns a destructive control, never adopts a selector it has not just seen resolve,
and never sends page *content* anywhere. See [Self-healing](#self-healing) below.

**Errors are data, not exceptions.** Every tool returns the same envelope whether it
succeeds or fails, and failures carry a machine-readable code plus a `remediation` string.
An agent can read *why* something failed and adapt, instead of blindly retrying.

**Conservative by default.** Rate limits, daily action quotas, a confirmation gate on
destructive tools, and a global dry-run switch. LinkedIn restricts accounts that behave
inhumanly, and a restriction costs far more than a slow run.

**It says what it does not know.** Sections LinkedIn withholds are reported as
`unavailableSections`. Generated resumes list their `gaps`. Inferred values — like whether
someone is a recruiter — carry a score and the signals behind it. Nothing is silently
invented.

---

## Install

Requires **Node.js 20+**.

### From npm

The shortest path from nothing to working tools, on any platform:

```bash
npm install -g linkedin-mcp-bridge   # 1. install
linkedin-mcp-setup                    # 2. sign in, and register with Claude Code
```

Step 2 opens a real Chrome window at LinkedIn. Sign in by hand — passwords, 2FA and
CAPTCHAs stay between you and LinkedIn, and this server never sees them. When you are
in, it offers to register itself with Claude Code and to set the profile path in the
server's `env` block. Restart your client and the `linkedin_*` tools are there.

Other binaries in the package:

```bash
linkedin-mcp-bridge                   # stdio server (what your client spawns)
linkedin-mcp-http                     # streamable HTTP server
```

Alternatively, skip the install and point your client at `npx -y linkedin-mcp-bridge`,
which fetches the package on first run. You still need to sign in once — run
`npx -y -p linkedin-mcp-bridge linkedin-mcp-setup`. See [Authenticate](#authenticate).

### From source

```bash
git clone https://github.com/Sabari2005/linkedin-mcp-server.git
cd linkedin-mcp-server
npm install            # installs deps and the Chromium browser
cp .env.example .env
npm run setup:profile  # sign in by hand; see Authenticate below
npm run build
```

Verify:

```bash
npm run cookies:check   # is the session valid?
npm run tools:list      # what can it do?
npm run smoke           # exercise every tool against your account (dry run)
```

---

## Authenticate

This server never asks for your password. You sign in to LinkedIn yourself, in a
real browser, and the server reuses that session — so 2FA, CAPTCHAs and device
confirmations all work normally.

### Nothing to configure (default)

Install, add the server to your client, and ask it something:

```text
> Show my LinkedIn profile

A Chrome window opens at the LinkedIn login page.
Sign in there. The request completes on its own.
```

The first call that needs authentication creates a browser profile, opens it at
LinkedIn, and waits. Sign in quickly and the same call returns your data; take
longer and it says *"sign in, then ask again"* while the window stays open — the
next request goes through. You only ever sign in once: the session lives in the
profile and survives restarts.

The profile lives outside the repo, one per machine:

| OS | Path |
|---|---|
| Windows | `%LOCALAPPDATA%\linkedin-mcp\chrome-profile` |
| macOS | `~/Library/Application Support/linkedin-mcp/chrome-profile` |
| Linux | `$XDG_DATA_HOME/linkedin-mcp/chrome-profile` |

Set `LINKEDIN_AUTO_LOGIN=false` to turn this off and get a plain error instead.

### Sign in up front (optional)

To choose where the profile lives, or to authenticate before wiring up a client:

```bash
npm run setup:profile        # from source
linkedin-mcp-setup           # installed globally
```

It prints one line for your `.env`:

```dotenv
LINKEDIN_BROWSER_PROFILE=/path/it/printed
```

Either way the profile is **dedicated on purpose**: Chrome 136+ refuses to be
automated against your primary user-data directory, and it fails by hanging for
three minutes rather than saying so. A separate profile also means you never have
to close your everyday Chrome to let the server run.

### Cookie capture (alternative)

```bash
npm run login
```

Best when there is no Chrome to keep around — a headless server, a container. It
opens a browser once, captures the session to a file, and reuses it. That file is
a credential, and it expires when LinkedIn rotates or you change your password.

Manual export via the Cookie-Editor extension also works — see
[docs/authentication.md](docs/authentication.md).

> **Set exactly one strategy.** `LINKEDIN_LI_AT` outranks `LINKEDIN_COOKIE_FILE`,
> which outranks `LINKEDIN_BROWSER_PROFILE`. A leftover cookie-file line silently
> ignoring your profile is the single most common setup failure. The server warns
> at startup when it sees more than one.

Check what it resolved to at any time:

```bash
npm run cookies:check
```

---

## Connect a client

Two transports, same 102 tools:

| Transport | Command | Use it for |
|---|---|---|
| **stdio** | `npx -y linkedin-mcp-bridge` | Editor and CLI clients that spawn the server themselves |
| **streamable HTTP** | `npx -y -p linkedin-mcp-bridge linkedin-mcp-http` | Hosted clients that can only reach a URL |

Every config below uses `npx`, which downloads the package on first run and needs no
clone, no build and no absolute paths. The `-y` skips the install prompt, which a client
launching the server has no way to answer.

On the very first authenticated call the server opens a Chrome window for you to sign in
(see [Authenticate](#authenticate)). Nothing else is required up front.

<details>
<summary>Running from a clone instead</summary>

Replace `"command": "npx", "args": ["-y", "linkedin-mcp-bridge"]` with
`"command": "node", "args": ["/absolute/path/to/dist/index.js"]` after
`npm install && npm run build`. Use an **absolute** path — clients do not launch from your
project directory.

</details>

### Claude Code

`linkedin-mcp-setup` offers to do this for you. To do it by hand:

```bash
claude mcp add linkedin --scope user -- npx -y linkedin-mcp-bridge
claude mcp list   # → linkedin: ✔ Connected
```

Use `--scope user`, not the default `local`. A local-scoped server is recorded under
`projects["<cwd>"]` in `~/.claude.json` and only loads when Claude Code starts from that
exact directory — from anywhere else the tools simply are not there, which is easy to
mistake for a broken server.

### Cursor

`.cursor/mcp.json` in the project, or `~/.cursor/mcp.json` globally:

```json
{
  "mcpServers": {
    "linkedin": {
      "command": "npx",
      "args": ["-y", "linkedin-mcp-bridge"]
    }
  }
}
```

### Windsurf

`~/.codeium/windsurf/mcp_config.json` — same shape as Cursor:

```json
{
  "mcpServers": {
    "linkedin": {
      "command": "npx",
      "args": ["-y", "linkedin-mcp-bridge"]
    }
  }
}
```

### GitHub Copilot (VS Code)

`.vscode/mcp.json` in the workspace. Note Copilot's key is `servers`, not `mcpServers`:

```json
{
  "servers": {
    "linkedin": {
      "type": "stdio",
      "command": "npx",
      "args": ["-y", "linkedin-mcp-bridge"]
    }
  }
}
```

Then open the Copilot Chat **Agent** mode tool picker to enable the LinkedIn tools.

### OpenAI Codex CLI

`~/.codex/config.toml`:

```toml
[mcp_servers.linkedin]
command = "npx"
args = ["-y", "linkedin-mcp-bridge"]
```

### Claude Desktop

`claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "linkedin": {
      "command": "node",
      "args": ["C:\\absolute\\path\\to\\mcp\\dist\\index.js"]
    }
  }
}
```

- **Windows** `%APPDATA%\Claude\claude_desktop_config.json`
- **macOS** `~/Library/Application Support/Claude/claude_desktop_config.json`

### Hosted clients — ChatGPT, Claude web, Gemini, Antigravity

These cannot spawn a local process; they connect to a URL. Run the HTTP transport:

```bash
export MCP_HTTP_TOKEN="$(openssl rand -hex 32)"   # required for any non-loopback bind
npx -y -p linkedin-mcp-bridge linkedin-mcp-http     # → http://127.0.0.1:3000/mcp
```

Then add it as a custom connector with the URL and an
`Authorization: Bearer <token>` header.

A hosted client cannot reach your laptop directly, so expose the port with a tunnel
(`cloudflared tunnel --url http://127.0.0.1:3000`, `ngrok http 3000`, or a reverse
proxy) and give the client the public URL.

> **This endpoint acts as you on LinkedIn.** Anyone who can reach it can post, message
> and connect as you. The server refuses to bind to a non-loopback address unless
> `MCP_HTTP_TOKEN` is set — do not remove that check, and always terminate TLS at your
> tunnel or proxy.

| Variable | Default | Meaning |
|---|---|---|
| `MCP_HTTP_PORT` | `3000` | Port to listen on |
| `MCP_HTTP_HOST` | `127.0.0.1` | Bind address; non-loopback requires a token |
| `MCP_HTTP_TOKEN` | *(unset)* | Bearer token required on every request |
| `MCP_HTTP_PATH` | `/mcp` | Endpoint path (`/health` is always available) |

### Any other MCP client

Any stdio MCP client works: run `npx -y linkedin-mcp-bridge` and speak JSON-RPC over
stdin/stdout. Note that **stdout carries the protocol** — all logging goes to stderr
and `data/logs/`.

---

## First run

Start with dry-run enabled so nothing can touch your real account while you explore:

```dotenv
DRY_RUN=true
```

Every mutating tool then validates its arguments and reports what it *would* do without
doing it. Turn it off when you are ready.

Try these:

> Check my LinkedIn session status
> Read my LinkedIn profile and suggest improvements
> Search for remote machine learning jobs posted in the last week

---

## What it can do

| Category | Capabilities |
|---|---|
| **Profile** | Read complete profile · update headline, about, location · add/edit/delete experience, education, certifications, projects, publications, awards, volunteering, languages · manage skills · upload photo and banner · Open To Work · analytics · completeness analysis |
| **Posts** | Text, image, video, document/carousel and poll posts · edit · delete · react · comment · reply · repost · save · local drafts · read feed and activity |
| **Network** | Search people · connection requests with notes · accept/ignore/withdraw invitations · follow/unfollow · remove connections · list connections, followers, following |
| **Messaging** | Read conversations and full history · search · send and reply · archive, delete, mark read/unread |
| **Jobs** | Search with every LinkedIn filter · full job details · extracted skills · hiring team · save/unsave · saved and applied lists |
| **Applications** | Easy Apply automation with form introspection · answer memory · bulk apply · application history · withdraw |
| **Companies** | Company profiles · search · follow/unfollow · employees · hiring teams · open roles |
| **Recruiters** | Find recruiters with confidence scoring · personalised outreach drafting · bulk messaging |
| **Documents** | Job-fit analysis · tailored resumes · cover letters — all grounded in real profile data |
| **Search** | Every vertical: people, jobs, companies, posts, events, groups, schools |
| **Export** | Profiles, jobs, companies, connections, conversations, posts, search results → JSON, CSV, JSONL, Markdown |
| **Notifications** | Read, mark read, delete |
| **Settings** | Read all categories · change the few toggles that are safe to automate · trigger LinkedIn's official data export |

Full catalogue: `npm run tools:list`, or ask the assistant to call `linkedin_list_tools`.

---

## Example workflows

**Tailor an application end to end**

> Find remote LLM engineer jobs in Germany posted this week. For the three best matches,
> analyse how well my profile fits, generate a tailored resume for each, and draft a cover
> letter addressed to someone on the hiring team.

The assistant chains `linkedin_search_jobs` → `linkedin_analyse_job_fit` →
`linkedin_generate_resume` → `linkedin_get_hiring_team` → `linkedin_generate_cover_letter`.

**Bulk apply, safely**

> Apply to all Easy Apply data engineering jobs in Berlin using resume.pdf. My phone is
> +49 30 12345678 and I have 5 years of Python experience.

`linkedin_apply_to_jobs_bulk` introspects each employer's form, answers from what you
supplied plus remembered answers, and **stops rather than guessing** at any required
question it cannot answer confidently — reporting exactly which ones, so you can supply
them and retry.

**Improve a profile**

> Read my profile, score it, then rewrite my About section to target AI engineering roles
> and add the three skills I am missing.

**Recruiter outreach**

> Find recruiters hiring AI engineers in Berlin, draft a personalised message for each
> based on my background, and show me the drafts before sending anything.

More in [`examples/`](examples/).

---

## Safety

The defaults assume you would rather be slow than restricted.

| Control | Default | Purpose |
|---|---|---|
| `DRY_RUN` | `false` | When true, mutating tools validate and report but never execute |
| `REQUIRE_CONFIRMATION` | `true` | Destructive tools need explicit `confirm: true` |
| `RATE_LIMIT_REQUESTS_PER_MINUTE` | `30` | Global request pacing |
| `RATE_LIMIT_CONNECTIONS_PER_DAY` | `80` | Below LinkedIn's ~100/week invitation ceiling |
| `RATE_LIMIT_MESSAGES_PER_DAY` | `100` | Messaging volume cap |
| `RATE_LIMIT_APPLICATIONS_PER_DAY` | `50` | Application volume cap |
| `HUMANIZE` | `true` | Randomised delays and human-like typing |
| `BROWSER_STEALTH` | `false` | Anti-detection, off by default — automating your own account should not require concealing it |
| `SELECTOR_REPAIR` | `true` | Relearn moved selectors via your model; never for destructive controls |

Daily quotas persist to disk, so restarting the server cannot be used to sidestep them.

**Raising these limits materially increases the risk of a temporary account restriction.**

---

## Self-healing

LinkedIn rewrites its markup continuously and hashes class names per build. The usual
result is a control that quietly stops resolving, a fix that is one line long, and a wait
for someone to ship it.

Instead, when every shipped selector misses, the server asks *your* model to work out where
the element went:

1. It captures a **structural outline** of the page — tags, roles, ARIA labels, stable
   `data-*` hooks, href shapes. Never text.
2. It asks for candidate selectors via [MCP sampling](https://modelcontextprotocol.io),
   so the request goes to the model your client is already running. **No API key, no
   separate billing, no third-party service.**
3. It **verifies** each candidate against the live DOM and keeps only what resolves.
4. It remembers what worked and tries it first next time.

Requires a client that supports sampling. Without one the feature stays off and selector
failures behave exactly as before.

### What it will not do

| Guard | Behaviour |
|---|---|
| **Destructive controls** | `delete`, `remove`, `withdraw`, `block`, `unfollow` and similar are never relearned. A wrong guess there cannot be undone, so they fail loudly instead. |
| **Unverified suggestions** | A selector is adopted only after it resolves to a visible element on the page that just failed. The model proposes; the DOM decides. |
| **Over-broad matches** | A candidate matching more than `SELECTOR_REPAIR_MAX_MATCHES` elements is rejected, and the model is asked once for something narrower. On a profile page a bare `[aria-label*="to connect"]` matches ~12 elements — the sidebar rail included — which is exactly how automation ends up acting on the wrong person. |
| **Page content** | Text nodes are dropped and identities redacted *inside the page*, before anything is serialised. `Invite Kamal M to connect` → `Invite ▮ to connect`. Stored repairs record `/in/*`, never a real profile URL. |

### Reviewing repairs

```
"show me any selectors the server has relearned"     → linkedin_selector_repairs
"forget the relearned connect button"                → linkedin_selector_repairs { forget: true, label: "connect button" }
```

Repairs are local to your machine. If one works, **please open a PR with it** — that is how
the fix reaches everyone else instead of each user relearning it independently.

Turn the whole thing off with `SELECTOR_REPAIR=false`.

---

## Configuration

Every setting lives in `.env` — see [`.env.example`](.env.example) for the annotated list.
The most useful:

```dotenv
TRANSPORT_STRATEGY=auto      # auto | api | browser
BROWSER_HEADLESS=true        # false to watch automation live (great for debugging)
LOG_LEVEL=info               # trace | debug | info | warn | error | silent
DEBUG_ARTIFACTS=true         # screenshot + HTML dump on browser failures
CACHE_TTL_MS=300000          # read cache lifetime
```

Set exactly one authentication source — see [Authenticate](#authenticate).

---

## Troubleshooting

**`PROFILE_LOCKED`**
Something else is using the Chrome profile — Chrome allows exactly one process per
user-data directory. **Your session is fine; do not re-export cookies.** Usually a second
copy of this server, a `linkedin-mcp-setup` window still open, or a Chrome you launched
from that profile. Close the extra one, or restart your MCP client if it has accumulated
duplicate servers, then retry.

Older versions reported this as `AUTH_EXPIRED`, which sent people to redo a sign-in that
was never broken. If you see that on an old build, upgrade.

**`AUTH_EXPIRED` / `AUTH_MISSING`**
Run `npm run cookies:check` — it reports which strategy resolved and flags conflicting
ones. If the session is dead, re-run `npm run setup:profile` (or `npm run login`). See
[docs/authentication.md](docs/authentication.md) — in particular the section on cookies
exported from the login page, which look valid but are not.

**My browser profile is being ignored**
`LINKEDIN_LI_AT` and `LINKEDIN_COOKIE_FILE` both outrank `LINKEDIN_BROWSER_PROFILE`.
Comment them out. `npm run cookies:check` names the winner.

**`SELECTOR_NOT_FOUND` / `UI_CHANGED`**
LinkedIn changed its markup. Check the screenshot in `data/artifacts/`, then add a
candidate to the relevant list in [`src/browser/selectors.ts`](src/browser/selectors.ts).
No other code needs to change.

**`RATE_LIMITED`**
Back off. Lower `RATE_LIMIT_REQUESTS_PER_MINUTE`. Sustained throttling can escalate.

**`QUOTA_EXCEEDED`**
A self-imposed safety limit, not LinkedIn. Wait for the window to roll over, or raise the
corresponding `RATE_LIMIT_*` value if you accept the risk.

**Browser will not launch**
`npx playwright install chromium`. If using `LINKEDIN_BROWSER_PROFILE`, close Chrome
completely — Chromium cannot share a locked profile.

**Browser hangs for ~180s, then times out**
Chrome 136+ refusing to automate a primary Chrome user-data directory. The silent hang
*is* the symptom — there is no error until the timeout. Run `npm run setup:profile` to
create a dedicated profile and point `LINKEDIN_BROWSER_PROFILE` at that instead.

**Watch it work**
Set `BROWSER_HEADLESS=false` and `LOG_LEVEL=debug`.

---

## Architecture

```
src/
  core/        config, logger, errors, tool registry, response envelope,
               retry/backoff, rate limiting, TTL cache, shared types
  auth/        cookie parsing and normalisation, session lifecycle
  browser/     Playwright manager, LinkedInPage wrapper, selector registry
  voyager/     internal API client and normalized-JSON graph decoder
  domains/     profile, posts, network, messaging, jobs, applications,
               companies, recruiters, search, notifications, settings, documents
  tools/       MCP tool definitions (schemas + descriptions)
  storage/     local store for applications, drafts, remembered answers
  export/      JSON / CSV / JSONL / Markdown writers
  cli/         login, session check, cookie import, tool listing
```

The **registry** ([`src/core/registry.ts`](src/core/registry.ts)) is the keystone: it wraps
every tool with argument validation, rate limiting, quota enforcement, dry-run
interception, confirmation gating, timing and error normalisation. That is why individual
tool handlers stay short — the repetitive parts happen exactly once.

---

## Development

```bash
npm run dev        # watch mode
npm run typecheck  # strict type check
npm run build      # compile to dist/
```

Adding a tool: write the domain logic in `src/domains/`, define the tool with
`defineTool()` in `src/tools/`, and add it to the array in
[`src/tools/index.ts`](src/tools/index.ts). The registry handles everything else.

### Smoke test

`npm run smoke` exercises **every registered tool against your live session**. It forces
`DRY_RUN=true`, so reads hit LinkedIn for real while writes are intercepted and validated
without being sent. Fixtures (a post, a job, a conversation) are discovered from your own
account at runtime — nothing is hardcoded.

```bash
npm run smoke                      # every tool
npm run smoke -- --only jobs -v    # one category, with failure detail
npm run smoke -- --skip-writes     # reads only
npm run smoke -- --json            # machine-readable report
```

A handful of tools are skipped by design: those that print live credentials
(`auth_export_cookies`), tear down the session the suite depends on (`system_reset`,
`auth_reload`), or have irreversible LinkedIn-side effects even in dry run
(`request_data_export`, `mark_notifications_read`). Tools whose fixture is missing from
your account — messaging tools on an empty inbox, for example — are reported as skipped
rather than passed.

### Releasing

Releases are tag-driven and fully automated by
[`.github/workflows/publish-mcp.yml`](.github/workflows/publish-mcp.yml). Pushing a `v*`
tag publishes the npm package and then registers it with the
[MCP Registry](https://registry.modelcontextprotocol.io).

```bash
npm version patch          # bumps package.json
# bump "version" and packages[0].version in server.json to match
npm run validate:registry  # fails loudly if anything disagrees
git commit -am "Release v1.0.4" && git tag v1.0.4
git push origin main --tags
```

[`server.json`](server.json) is the registry manifest. `npm run validate:registry` checks
it against the `$schema` it declares using ajv, then verifies the invariants the schema
cannot express:

| Invariant | Why it matters |
| --- | --- |
| `package.json` `mcpName` ≡ `server.json` `name` | How the registry proves you own the npm package. Compared **byte for byte** — `io.github.Sabari2005` must keep its capitals. |
| `packages[0].identifier` ≡ `package.json` `name` | The registry resolves the npm package from this field. |
| tag ≡ `package.json` ≡ `server.json` version | Three places, one number. The workflow refuses to publish a release whose tag disagrees. |
| `repository.url` owner ≡ namespace owner | GitHub OIDC only grants `io.github.<owner>/*`. |

The workflow authenticates to the registry with **GitHub OIDC**, so the only secret the
repository needs is `NPM_TOKEN` (an npm automation token). npm publish runs first —
registry validation fetches the published package — and is skipped if that version is
already on npm, so a re-run after a partly failed release still completes.

---

## Limitations

Stated plainly, because silent failure is worse than a clear "no":

- **Premium features** (InMail to strangers, full analytics, Recruiter) need a paid
  account. Tools report this rather than failing obscurely.
- **Scheduled posting** is not supported — LinkedIn's scheduler has no automatable surface.
  Use local drafts plus your own scheduler.
- **Most account settings** are read-only here. LinkedIn's settings UI has no stable hooks
  and a mis-click has real privacy consequences, so only unambiguous toggles are writable;
  the rest come with direct links.
- **Skill extraction** from job descriptions is keyword matching against a curated
  vocabulary — deliberately conservative, since a false "requires Rust" is worse than an
  omission.
- **Recruiter identification** is inferred from headline patterns; LinkedIn has no
  recruiter entity type. Every match carries a score and its reasoning.
- **Resume generation** assembles and organises real profile data. It never invents
  experience, and reports what it could not source.

---

## Responsible use

This automates *your own* LinkedIn account, from your own machine, using a session you
signed into yourself. There is no server in the middle and no credential ever leaves your
computer. You remain responsible for what it does.

**Read this before you use it.** LinkedIn's
[User Agreement](https://www.linkedin.com/legal/user-agreement) restricts automated access
— §8.2 prohibits using "software, devices, scripts, robots… to scrape the Services" and
using "bots or other automated methods to access the Services." Using this tool may
violate that agreement, and **LinkedIn can restrict or terminate accounts for it.** That
risk is yours, it is real, and no amount of care in this codebase removes it.

The conservative defaults exist for that reason. Keep them, prefer targeted actions over
volume, and treat a daily quota as a ceiling rather than a target.

What this deliberately does **not** do:

- **No credential handling.** It never accepts, stores, or types a password. Authentication
  is a browser session you established yourself.
- **No evasion by default.** `BROWSER_STEALTH` is off, and LinkedIn's own bot-detection
  endpoint is not blocked. Automating your own account does not require concealing that it
  is automated, and the project takes the view that it should not try to.
- **No bulk harvesting.** There is no multi-account support, no unattended crawling, and no
  mass outreach. Volume is what gets accounts restricted and what makes a tool a scraper.
- **No data collection by us.** No telemetry, no analytics, no phone-home. Everything stays
  in your `data/` directory.

If you export other people's data, that data is theirs and may be regulated where you or
they live (GDPR, India's DPDP Act, and similar). Exporting a profile is a decision with
obligations attached; the tool will not make it for you.

Do not use this to spam, to scrape people who have not consented, or to misrepresent
qualifications. Generated resumes and letters are drafts built from your real profile —
review them before they reach another person.

---

## Trademark

This is an independent, unofficial project. It is **not affiliated with, endorsed,
sponsored by, or connected to LinkedIn Corporation** in any way. "LinkedIn" is a
trademark of LinkedIn Corporation, used here only to describe what this software
interoperates with.

---

## License

MIT — provided "as is", without warranty of any kind. See [LICENSE](LICENSE).

TDQS

A3.6/5.0

Scored across 101 tools

Disambiguation4/5

Almost every tool has a clearly distinct purpose, and the descriptions are exemplary at resolving overlaps (e.g., linkedin_get_applied_jobs vs linkedin_get_application_history, linkedin_export_connections vs linkedin_request_data_export). A few nearby pairs like linkedin_send_message vs linkedin_reply_to_conversation retain slight overlap, but misselection is unlikely given the detailed guidance.

Naming Consistency4/5

All 101 tools follow a consistent linkedin_verb_noun snake_case pattern with a uniform prefix. Minor deviations keep it from a perfect score: profile sections use add_* while posts use create_post; edit_* and update_* are both used for modifications; and delete_*, remove_*, and withdraw_* all express removal.

Tool Count1/5

At 101 tools, this is far beyond the 50+ threshold the rubric flags as an extreme mismatch. While LinkedIn's surface area is genuinely large and each tool has a defined role, the count is overwhelming for an agent to navigate effectively.

Completeness4/5

The server covers LinkedIn's core workflows end to end: profile CRUD, job search/apply/withdraw, messaging, connection management, posts, notifications, search, settings, and data export. Minor gaps like endorsements, recommendations, and Featured-section management exist, but no essential flow is left as a dead end.

Maintenance

ActivityMaintained
ResponsivenessNo issues