Skip to content

Releases: JohannsenLum/linkedin-api-mcp

v1.0.1

Choose a tag to compare

@JohannsenLum JohannsenLum released this 12 Aug 07:48

[1.0.1] - 2026-08-12

Security

  • search_posts fences the author's headline. It fenced the post body but
    returned author_headline raw, 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_company fences the tagline. It fenced the about blurb but returned
    tagline with only whitespace normalisation. A tagline is prose the company
    writes about itself, so it is untrusted in the same sense as a profile
    headline, and search_companies returns companies the user has no relationship
    with. Closes #18.

    name stays 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) and company.tagline (#18),
    each found by reading code rather than by a failing test.

    tests/test_fencing_coverage.py walks 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: tagline is 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

Choose a tag to compare

@JohannsenLum JohannsenLum released this 10 Aug 14:45

[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 holds send_message
    and connect. The README already promised that human-written text is fenced and
    about was, but headline was not, so the guarantee was only partly true.

    Now fenced at all three sites, get_profile, the search_people structural row
    path, and the selector fallback, through the same clean, truncate, fence
    pipeline as about, with fence outermost so its boundary nonce is still
    generated after the content exists. _HEADLINE_LIMIT is 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 two search_people paths 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.py gained 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: headline is now a fenced string rather than a raw value in
    get_profile and search_people. Callers matching on exact content must match
    on containment instead.

v0.0.4

Choose a tag to compare

@JohannsenLum JohannsenLum released this 09 Aug 19:15
uvx linkedin-api-mcp@latest

Experience 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/i

LinkedIn 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_id is null from 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

Choose a tag to compare

@JohannsenLum JohannsenLum released this 09 Aug 18:41

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 --test

Why 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.title and href patterns 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_failed with a hint to check your network. It now says the cookie is malformed.
  • auth raised an unhandled EOFError in any piped or containerised shell. pbpaste | linkedin-api-mcp auth works 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_id is null from 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.
  • experience and education load 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

Choose a tag to compare

@JohannsenLum JohannsenLum released this 08 Aug 16:35

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 works

14 tools

Readget_profile · get_my_profile · search_people · get_inbox · get_conversation · search_conversations · get_company · search_companies · search_jobs · get_job · search_posts · linkedin_status

Writesend_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.