Releases: JohannsenLum/linkedin-api-mcp
Release list
v1.0.1
[1.0.1] - 2026-08-12
Security
-
search_postsfences the author's headline. It fenced the post body but
returnedauthor_headlineraw, so the tool that surfaces posts from strangers
was the one place untrusted free text still reached the model unmarked. Fenced
on both the structural row path and the selector fallback, with a 220 character
limit matching LinkedIn's own headline cap. Contributed by @adity982 in #15,
closing #13. -
get_companyfences the tagline. It fenced theaboutblurb but returned
taglinewith only whitespace normalisation. A tagline is prose the company
writes about itself, so it is untrusted in the same sense as a profile
headline, andsearch_companiesreturns companies the user has no relationship
with. Closes #18.namestays plain, and a test now pins that: it is a short identifier callers
match on, not prose, so the asymmetry reads as a decision rather than another
missed field.
Added
-
A structural guard for the fencing promise. Every other fencing test named
one field, so the README's "fencing on all scraped free text" was enforced by
attention rather than by the suite. Three fields had already slipped through
that way:headline(#6),author_headline(#13) andcompany.tagline(#18),
each found by reading code rather than by a failing test.tests/test_fencing_coverage.pywalks the tool modules and fails if any
prose-shaped key is returned without a fence, against an explicit allowlist of
structural keys. Adding to that allowlist is a visible decision in review.
Verified by reverting all three historical fixes in turn: the guard catches
every one. Closes #19.
Changed
- Breaking, patch-level:
taglineis now a fenced string rather than a raw
value. Callers matching on exact content must match on containment. Treated as
a patch because the README already promised this field would be fenced; the
code was not honouring its own documented contract.
v1.0.0
[1.0.0] - 2026-08-10
First stable release. The tool surface, the return shapes and the safety
guarantees are now a contract, and a breaking change to any of them means 2.0.0.
Security
-
Headlines are fenced as untrusted data. A LinkedIn headline is free text any
stranger can write, and it was reaching a model that also holdssend_message
andconnect. The README already promised that human-written text is fenced and
aboutwas, butheadlinewas not, so the guarantee was only partly true.Now fenced at all three sites,
get_profile, thesearch_peoplestructural row
path, and the selector fallback, through the sameclean,truncate,fence
pipeline asabout, withfenceoutermost so its boundary nonce is still
generated after the content exists._HEADLINE_LIMITis 220, matching
LinkedIn's own headline cap, so truncation never fires on a real headline.Contributed by @VedantMadane in #12, closing #6.
-
Regression tests now guard the fencing. Previously only the
get_profile
site was covered, so the twosearch_peoplepaths could have been silently
unfenced by a refactor with the suite staying green. All three paths are now
tested, and the fence label is pinned as well as the wrapper, since a
mislabelled provenance marker is how a reader later mistakes one field for
another.tests/fakes.pygained a search-results fixture that future search
tests can reuse.
Added
- Contributing docs explain how to run the tests. The default suite needs no
Chromium, no LinkedIn account, no cookie and no network access, which was true
but written down nowhere. Contributed by @averyquinnhq in #11, closing #7.
Changed
- Breaking:
headlineis now a fenced string rather than a raw value in
get_profileandsearch_people. Callers matching on exact content must match
on containment instead.
v0.0.4
uvx linkedin-api-mcp@latestExperience and education now return data
They came back empty in 0.0.3, and the assumption was lazy loading: sections that render once you scroll. That was wrong. Scrolling six full screens leaves the page text byte-identical, 11,659 characters every time.
They are not on the profile page at all. They live at their own routes, /in/<id>/details/experience/ and /details/education/, fully rendered. One navigation gets the lot.
experience: 4 entries
'Technology Lead' at 'Twiss' (Sep 2025 - Present, 1 yr)
'Tech Intern' at 'Biohackk, Internship' (May 2025 - Aug 2025, 4 mos)
education: 2 entries
'National University of Singapore' 'BComp (CS), Computer Science'
Entries are found with the same rule the search parser uses: an entry is the smallest ancestor holding exactly one company or school link. The date range is the one field with a recognisable shape, a year plus a dash or "Present", so what precedes it is title and organisation and what follows is location and description. No positional assumptions.
Education is labelled separately, because LinkedIn lists the school first and the qualification second, the reverse of experience. Naming those by position would have quietly swapped them, and a university name sitting in a field called title looks plausible enough to survive review.
Each section costs one extra navigation, so the caller asks for them rather than paying by default.
One-click install
Buttons for Claude Code, Claude Desktop, Cursor, VS Code, Zed, Windsurf and Codex CLI. The Cursor and VS Code deeplink payloads were decoded and confirmed to produce exactly uvx linkedin-api-mcp, because a malformed payload silently installs a broken server and the person who clicks it never comes back.
Plus an animated header showing a people search, then a message being sent and confirmed by reading the thread back.
A bug worth recording
The author asked for no em-dashes anywhere in the project. A sweep removed them, including one inside a regex character class matching date separators:
/[-<en-dash><em-dash>]|Present|\bto\b/iLinkedIn writes education ranges as Aug 2023 <en-dash> May 2027, so that class must match an en-dash. Removing it turned the class into a character range that matched almost any field containing a year.
It is written as – and — escapes now, so a style rule and the code can no longer collide.
Still open
conversation_idisnullfrom the inbox: LinkedIn binds each row's click to an in-memory object rather than a URL. See #2.- Post reaction and comment counts return
null. See #1.
Disclaimer: This is an independent, community project. It is not affiliated with, authorized by, endorsed by, or sponsored by LinkedIn Corporation or Microsoft. "LinkedIn" is a registered trademark of LinkedIn Corporation and is used here only descriptively to identify the third-party service this software interoperates with.
v0.0.3
The first version whose parsers have actually been run against LinkedIn. 0.0.1 and 0.0.2 returned parse_failed on every page, and send_message reported success for messages it never sent. If you installed either, upgrade.
uvx linkedin-api-mcp@latest auth
uvx linkedin-api-mcp@latest --testWhy the old versions did not work
LinkedIn ships hashed, per-build CSS class names now (b0712e9a, _129ac5aa). The stable-looking classes every scraper targets no longer exist, the id="experience" section anchors are gone, and a profile page has zero h1 elements. Anything matching on a class returns nothing.
Parsing now anchors on what a rebuild cannot rename:
document.titleandhrefpatterns for identity- a result row is the smallest ancestor containing exactly one entity link
- fields classified by shape, not position, so a 22-character headline and a 60-character one parse the same way
- text nodes walked and repeats dropped, because LinkedIn renders every label twice, once visible and once for screen readers
Verified against a live account
| Tool | |
|---|---|
get_my_profile, get_profile |
name, headline, location, public id |
search_people |
name, degree, headline, location |
get_company, search_companies |
name, industry, size, headquarters |
search_jobs |
title, company, location, on-site/remote |
search_posts |
author and body; counts null |
get_inbox |
participant, preview, timestamp; conversation_id null |
send_message |
delivered, then confirmed by reading the thread back |
The bug worth reading about
send_message used to click a send button and return. Nothing checked delivery, so a wrong-but-clickable element produced {"sent": true} for a message that never existed. Confirmed against a real account: the tool said sent, the message was not there.
A read tool that lies gives you wrong information. A write tool that lies makes you believe you contacted someone, and you wait for a reply that is not coming.
Both send paths now poll until the text appears in the conversation and raise send_unconfirmed otherwise, telling you to check the browser before retrying so a partial send never becomes two messages.
Related: the Message control is an <a> pointing at /messaging/compose/?recipient=, and two decoys sit beside it in the same card. Links with screenContext=PROFILE_HIGHLIGHTS carry a prefilled body= ("Hi Basil, we both worked at PayPal..."), and sidebar links point at a different person. Either would have sent the wrong thing, and delivery verification would have happily confirmed it. Both are rejected now.
Also fixed
- A malformed cookie sends LinkedIn into a redirect loop. That was reported as
navigation_failedwith a hint to check your network. It now says the cookie is malformed. authraised an unhandledEOFErrorin any piped or containerised shell.pbpaste | linkedin-api-mcp authworks now.- The profile top card picked the organisation line as the headline for anyone whose headline is under 25 characters.
Known gaps, stated plainly
conversation_idisnullfrom the inbox. LinkedIn binds each row's click to an in-memory object rather than a URL, so there is nothing in the row to read.- Post reaction and comment counts return
null. experienceandeducationload only on scroll and are still empty.
Disclaimer: This is an independent, community project. It is not affiliated with, authorized by, endorsed by, or sponsored by LinkedIn Corporation or Microsoft. "LinkedIn" is a registered trademark of LinkedIn Corporation and is used here only descriptively to identify the third-party service this software interoperates with.
v0.0.1
First release. An MCP server for LinkedIn that drives a real browser using your own logged-in session.
uvx linkedin-api-mcp auth # store your li_at cookie in the OS keyring
uvx linkedin-api-mcp --test # verify it works14 tools
Read — get_profile · get_my_profile · search_people · get_inbox · get_conversation · search_conversations · get_company · search_companies · search_jobs · get_job · search_posts · linkedin_status
Write — send_message · connect. Separately annotated, because they take actions another person sees.
What's different about it
A queue, not a promise. Every tool runs through one action queue: one LinkedIn action at a time, a floor between actions, and an hourly ceiling that refuses rather than sleeps. An agent in a retry loop is the realistic way an account gets restricted. When the ceiling trips, the model is told to stop and report — not to wait. linkedin_status shows where you stand.
Untrusted text is fenced. get_conversation and search_posts return text anyone on LinkedIn can write to you, to a model that also holds send_message and connect. That text is wrapped in delimiters carrying a nonce generated after the content exists, so injected text can't pre-forge a closing boundary.
The cookie is treated as the account. li_at survives a password change, and LinkedIn offers no way to audit live sessions. It lives in the OS keyring, never in a log, never in a return value. Errors report a failure class rather than interpolating exceptions, because Playwright embeds the URL it was driving in its exception text.
63 tests, no browser and no network — a missing LINKEDIN_COOKIE in CI is what guarantees no test can touch a real account.
Is this safe to use? Will I get banned?
This tool controls a real browser session; it doesn't exploit undocumented APIs or bypass authentication. LinkedIn's User Agreement prohibits automated access, and accounts using automated tools can be restricted or banned. Use at your own risk; there is no guarantee of account safety. If you encounter any issues, let me know in the Discussions.
Known limitation
The selectors have never run against live LinkedIn. Every parser has fallbacks and degrades gracefully, but expect some to need adjustment on first real use — parse_failed errors say plainly when that's the cause. Please report them.
Disclaimer: This is an independent, community project. It is not affiliated with, authorized by, endorsed by, or sponsored by LinkedIn Corporation or Microsoft. "LinkedIn" is a registered trademark of LinkedIn Corporation and is used here only descriptively to identify the third-party service this software interoperates with.