Skip to main content
Glama

linkedin-mcp

A Model Context Protocol server that drives your own logged-in LinkedIn session in a real Chromium browser, with an explicit confirmation step in front of every write.


⚠️ Read this first

This is not a normal API client. Please read all of this before you install it.

  • It drives a real, logged-in browser session as you. The server launches Chromium, restores your saved LinkedIn cookies, and clicks the same buttons a human would.

  • It does not use the official LinkedIn API. There is no OAuth app, no partner agreement, and no supported integration behind it. It is browser automation of the public website.

  • Automating LinkedIn this way operates outside LinkedIn's User Agreement. LinkedIn's terms prohibit scraping and automated access to the site.

  • Your account can be rate-limited, restricted, or permanently banned. That risk is real and it is not hypothetical. LinkedIn detects automation, and enforcement can arrive without warning and without appeal.

  • Your session cookies live on this machine. A successful login writes a Playwright storageState file to local disk. Anyone who can read that file can act as you on LinkedIn.

  • Use is entirely at the account owner's own risk. There is no warranty here, expressed or implied, and no mitigation for a suspended account.

Three design decisions follow from the above and are not configurable:

  1. Single account, single user. The server has one state directory holding one saved session. There is no multi-account support and no notion of "users" — it is a local tool for the person sitting at the keyboard.

  2. Every write action requires explicit confirmation. Posting, connecting, messaging, and applying are all two-call handshakes. A single tool call can never send anything to LinkedIn. See the next section.

  3. The server will never solve a CAPTCHA. It will not type your password, will not clear a 2FA prompt, and will not attempt to defeat a security checkpoint. When LinkedIn puts up a challenge, the server stops and tells you to handle it yourself in a normal browser.

If any of that is unacceptable for your account, do not install this.


Related MCP server: LinkedIn MCP Server

How confirm-before-execute works

Every write tool (linkedin_create_post, linkedin_send_connection_request, linkedin_send_message, linkedin_apply_to_job) is a two-call handshake:

Call 1 — preview. Call the tool with its real arguments and no confirm. The server inspects the page, works out exactly what would happen, and returns a preview envelope. Nothing is submitted, and your daily quota is not consumed.

{
  "profileUrl": "https://www.linkedin.com/in/dana-whitfield-example/",
  "note": "Hi Dana — we both worked on the OpenTelemetry collector. Would like to connect."
}

Call 2 — confirm. Re-issue the same call with confirm: true and the previewToken you were handed. The token is required: a confirm: true without one is refused with confirmation_required before the server checks your session, opens a browser or looks at your daily quota. The server also verifies that the arguments have not changed since the preview, and fails with confirmation_mismatch if they have.

{
  "profileUrl": "https://www.linkedin.com/in/dana-whitfield-example/",
  "note": "Hi Dana — we both worked on the OpenTelemetry collector. Would like to connect.",
  "previewToken": "c1f3a08de4b7526a91d0e5748b2c63af",
  "confirm": true
}

The preview envelope looks like this (a real linkedin_send_connection_request preview):

{
  "status": "preview",
  "action": "linkedin_send_connection_request",
  "executed": false,
  "confirmationRequired": true,
  "summary": {
    "profileUrl": "https://www.linkedin.com/in/dana-whitfield-example/",
    "name": "Dana Whitfield",
    "headline": "Staff Engineer, Observability",
    "note": "Hi Dana — we both worked on the OpenTelemetry collector. Would like to connect.",
    "noteLength": 88,
    "notePreview": "Hi Dana — we both worked on the OpenTelemetry collector. Would like to connect.",
    "connectionDegree": 2,
    "connectionDegreeLabel": "2nd",
    "connectPathway": "direct",
    "alreadyPending": false,
    "wouldSucceed": true
  },
  "previewToken": "c1f3a08de4b7526a91d0e5748b2c63af",
  "quota": {
    "action": "connectionRequests",
    "used": 3,
    "cap": 20,
    "remaining": 17,
    "resetsAt": "2026-08-25T07:00:00.000Z",
    "allowed": true
  },
  "warnings": [],
  "howToConfirm": "Nothing has been sent to LinkedIn yet. To execute, re-issue the exact same `linkedin_send_connection_request` call with `confirm: true` and `previewToken: \"c1f3a08de4b7526a91d0e5748b2c63af\"`. The token is required, single-use, and valid for 10 minutes."
}

Notes on reading a preview:

  • executed: false and confirmationRequired: true are always present on a preview. An executed result instead carries "status": "executed", "executed": true, and a result object.

  • warnings is where the server tells you about anything it is unhappy about — a truncated post, an over-long connection note, an exhausted quota, dry-run mode.

  • If the preview says the call cannot succeed (already connected, invitation already pending, no Connect control, not a 1st-degree connection), howToConfirm says so plainly and confirming will be refused with invalid_input without spending quota.

  • previewToken is a server-issued nonce: 16 random bytes, rendered as 32 hex characters. It exists only in the memory of the running server, is good for one confirm, expires 10 minutes after the preview, and is bound to the exact arguments that were previewed. You cannot compute one, and there is no way to write on a single call. Consequences worth knowing:

    • Restarting the server invalidates every outstanding preview — preview again.

    • A refused confirm (exhausted quota, expired session, a preview that said it could not succeed) does not burn the token: fix the cause and confirm again.

    • A successful confirm burns it, so a retried or duplicated confirm cannot post/send/apply twice. It comes back as confirmation_invalid.


Requirements

  • Node.js 20 or newer ("engines": { "node": ">=20" }).

  • A desktop environment that can open a visible browser window — interactive login requires one.

  • Playwright's Chromium build (installed below).

Setup

Install dependencies:

npm install

Download the Chromium build Playwright drives:

npx playwright install chromium

Create your environment file (every variable in it is optional, and it holds no credentials):

cp .env.example .env

Create your config file (daily caps and timeouts only):

cp config.example.json config.json

Compile TypeScript to dist/:

npm run build

First run, in order

Nothing here touches your LinkedIn account until the very last step.

  1. npm install

  2. npx playwright install chromium

  3. cp .env.example .env and cp config.example.json config.json — both optional, neither holds credentials

  4. npm run build

  5. npm test — 1255 hermetic unit cases; no browser, no network

  6. npm run verify:dry — drives all 11 tools against local fixtures, so you find out the wiring works before pointing it at your account (details)

  7. Register the server with your MCP client, with --dry-run in args first (details). Confirm your client lists 11 tools and that a write tool returns a preview.

  8. Drop --dry-run from args, restart your client, and call linkedin_login (details). This is the first step that reaches linkedin.com. A visible Chromium window opens and you sign in by hand.

  9. Call linkedin_session_status to confirm the saved session works.

  10. Optional, once you have a session: npm run verify:live then npm run verify:read-tools — the two read-only checks that tell you whether the selectors still match today's LinkedIn (details). Both refuse to write anything.

Step 8 is the boundary. Everything before it is reversible by deleting a directory.


Logging in

There is no credential configuration anywhere in this project — by design. You sign in by hand, once, in a real browser window.

  1. Call the linkedin_login tool. It takes no arguments.

  2. A real, visible Chromium window opens on LinkedIn's login page. It is visible even if you configured headless: true; interactive login always forces a headed browser.

  3. You type your email and password, and you clear whatever LinkedIn asks for next — an SMS or authenticator code, an email PIN, a device confirmation, a CAPTCHA. The server does not type credentials, does not read the password field, and does not touch challenge widgets. It just polls every two seconds, waiting to see a signed-in identity element in LinkedIn's navigation.

  4. Take your time. The wait is bounded by loginTimeoutMs, which defaults to 300000 (five minutes). Raise it in config.json if you need longer.

  5. Once LinkedIn shows a signed-in feed, the browser session is written to storageState.json inside owner-only state/profile directories (0700), with file permissions 0600 (owner read/write only). Login fails closed if those permissions cannot be enforced. That file is gitignored.

  6. Every later tool call reuses that saved session. You should not need to log in again for weeks.

Check on the session at any time with linkedin_session_status (no arguments). It loads the feed once and reports:

{
  "valid": true,
  "lastVerified": "2026-08-24T18:42:10.114Z",
  "sessionSavedAt": "2026-08-11T09:03:55.002Z"
}

When it reports valid: false it includes a reason (for example, that LinkedIn redirected the feed to its sign-in page). No cookie or session content is ever returned by this tool, or by any other.

Sessions expire. When they do, tools start failing with session_expired and the fix is always the same: run linkedin_login again and sign in by hand.


Rate limits & pacing

The server enforces its own daily caps and inserts a randomized delay before browser actions. This is the single most important protection your account has, and the defaults are conservative on purpose.

config.example.json — copy it to config.json and edit:

{
  "dailyCaps": {
    "connectionRequests": 20,
    "messages": 30,
    "posts": 5,
    "jobApplications": 10
  },
  "delayRangeMs": {
    "min": 1500,
    "max": 6000
  },
  "headless": false,
  "navigationTimeoutMs": 30000,
  "actionTimeoutMs": 15000,
  "loginTimeoutMs": 300000
}

The four daily caps

Cap

Limits

Default

connectionRequests

Invitations sent by linkedin_send_connection_request

20 / day

messages

Messages sent by linkedin_send_message

30 / day

posts

Posts published by linkedin_create_post

5 / day

jobApplications

Applications submitted by linkedin_apply_to_job

10 / day

How they behave:

  • A cap is only consumed on a confirmed execution. Previews report your current quota but never spend it, and a call the server refuses outright does not spend it either.

  • A cap counts actions that were actually published, sent or submitted — not attempts. The unit is reserved before the irreversible click and handed back if the attempt dies before reaching it, so a selector that went missing halfway through the composer, a Send button that stayed disabled, or an Easy Apply form with an unsatisfiable required question costs you nothing. See Reserve and refund below for exactly where the line falls.

  • Counts are persisted to counters.json in the state directory, so a server restart does not reset them.

  • When a cap is reached, the tool fails with rate_limited instead of acting. Setting a cap to 0 disables that action entirely.

  • Every preview and executed envelope carries a quota block with used, cap, remaining, resetsAt, and allowed.

Reserve and refund

Each write tool reserves its unit before it opens the composer, dialog or wizard, and returns it if the attempt fails before the one step that cannot be undone — the "Post" click, the "Send" click, the "Submit application" click. What survives a failure is therefore an accurate count of what LinkedIn actually accepted.

The asymmetry is deliberate, and it is not the same as counting afterwards:

  • Reserved, then failed before the click → the unit is returned. Nothing was published, sent or submitted, so nothing is charged. The server logs a write failed before its point of no return; the daily counter was refunded.

  • Reserved, then failed after the click → the unit stays spent. The action may well have landed and the server cannot know, so it errs towards under-reporting your remaining budget rather than under-reporting an action LinkedIn already has. The log line is a write failed after its point of no return; the daily counter stays consumed, and it is worth reading: if this appears, check LinkedIn before retrying.

  • The cap was already exhausted → nothing is reserved and nothing is refunded; the call fails with rate_limited before any browser work.

Two things are not refunded, on purpose:

  • A dry run still consumes. --dry-run drives every step against a local fixture and skips only the final click, and the counter still moves so quota behaviour under --dry-run matches quota behaviour for real. The executed envelope says so in its warnings.

  • The confirmation token is never refunded. Quota is a budget and can be handed back; the token is a safety interlock and cannot. A failed attempt burns its token, so a retry has to preview again and look at what it is confirming — the world may have changed in between. See How confirm-before-execute works.

The failure paths this depends on are covered by unit tests rather than by a live account (tests/quota.test.ts, plus the refund block in tests/rateLimiter.test.ts), and the dry-run sweep checks the counters file on either side of a refused Easy Apply to prove the refusal charged nothing.

Two servers, one counters file

Nothing stops you from pointing two MCP clients at the same state directory — a desktop client and a terminal one, or the same client restarted while the old process is still shutting down. Both would then read counters.json, both would see four of five posts used, and both would write five. The day would quietly get six.

So every update to the counters runs under a cross-process lock: a directory named after the counters file, counters.json.lock, created with mkdir — the one call that both creates and tests in a single atomic step on every platform this runs on. Whoever creates it owns the read-check-write section; everyone else waits and retries every 20 ms.

The details worth knowing:

  • Only writers take it. linkedin_session_status and the quota block in every envelope read the file directly and never wait, because counters are written tmp-then-rename: a reader sees the old file or the new one, never a half-written one. A usage query that could block behind a writer would be its own kind of bug.

  • A wait that goes nowhere fails in 5 seconds, with browser_error and a hint naming the exact directory to delete. It is deliberately not rate_limited — the cap is fine, the file is busy, and telling you to come back tomorrow would be a lie.

  • An abandoned lock is broken after 15 seconds. A process killed mid-update leaves the directory behind forever, and age is the only evidence available that nobody is coming back. Breaking one is logged at warn. If it comes straight back, the next holder is alive and this server queues behind it.

  • A refund never fails because of the lock. It runs on a path where something has already gone wrong; if it cannot take the lock it logs an error and reports the counter as it actually stands — still spent — rather than raising a second, more confusing error over the first.

  • You can delete counters.json.lock by hand if no server is running. Nothing else keys off it.

One live write at a time

The counter lock above is intentionally short-lived, so it cannot protect an entire browser workflow. Live calls to the four write tools therefore also run under a separate account-wide cross-process mutex, account-write.lock. The server acquires it before any write-tool preflight or preview and releases it only after the tool resolves or fails. This includes Easy Apply previews because opening that flow can create server-side draft state. Dry-run fixture calls bypass the mutex.

A second process waits up to five seconds, then receives write_in_progress without entering the tool, consuming a confirmation token, opening a browser, or reserving quota. A lock with a valid owner PID is reclaimed only when that process is provably gone; it is never stolen merely for being old. Release verifies a random ownership token, so an old process cannot delete a successor's lock. The directory and owner metadata are 0700 / 0600. If multiple MCP configurations use the same LinkedIn account but different state directories, set the same absolute LINKEDIN_MCP_ACCOUNT_WRITE_LOCK path in all of them.

Randomized delay

delayRangeMs is the range the server sleeps for, uniformly at random, before browser actions — by default 1500 ms to 6000 ms. Randomization matters: a fixed interval is a machine signature. min may equal max (a fixed delay) and min must not exceed max, or config loading fails with config_invalid.

Caps reset at local midnight

Counters are keyed on the local calendar date, so all four caps reset at midnight in your own timezone — not at UTC midnight, and not on a rolling 24-hour window. resetsAt in the quota block is that next local midnight, serialized as a UTC ISO timestamp.

Start lower than the defaults

The shipped defaults are a ceiling, not a recommendation. If your account is new, has few connections, or has never been automated before, start well below them — say connectionRequests: 5, messages: 5, posts: 1, jobApplications: 2 — and raise them slowly over weeks while watching for LinkedIn warnings. A burst of activity that looks nothing like your normal usage is exactly what gets accounts restricted.


Tool reference

Eleven tools, in the order the server registers them.

The four scrape/jobs tools below (linkedin_scrape_profile, linkedin_scrape_feed, linkedin_search_jobs, linkedin_apply_to_job) are documented from the shared contract in src/types.ts and src/selectors.ts. If your client's tools/list output disagrees with an argument name here, tools/list is authoritative — the server always reports its real schema.

linkedin_login

Opens a visible Chromium window and waits for you to sign in yourself, including any 2FA or CAPTCHA step. Saves the session to local disk on success. Takes no arguments. Not available under --dry-run.

{}

linkedin_session_status

Reports whether the saved session still works, when it was saved, and when it was last verified. Loads the feed once to check. Read-only. Always reports valid under --dry-run. Takes no arguments.

{}

linkedin_create_post (write — confirmation required)

Publishes a text post to your own feed. Consumes the posts cap.

Preview:

{
  "text": "Spent the week reading Playwright's tracing internals. Notes soon.",
  "visibility": "connections"
}

Confirm:

{
  "text": "Spent the week reading Playwright's tracing internals. Notes soon.",
  "visibility": "connections",
  "confirm": true
}
  • text — required, 1 to 3000 characters. Over ~1300 characters LinkedIn collapses the post behind "see more"; the preview warns you.

  • visibility — optional, "public" or "connections". Defaults to "public".

  • mediaUrl — optional URL. The server never fetches remote media. Passing it warns at preview and is hard-refused with invalid_input on confirm.

  • previewToken — the token from the preview call. Required whenever confirm is true; a confirm without it is refused with confirmation_required. Single-use, expires after 10 minutes.

  • confirm — optional boolean; must be exactly true to execute.

linkedin_send_connection_request (write — confirmation required)

Sends a connection invitation, optionally with a note. Consumes the connectionRequests cap.

Preview:

{
  "profileUrl": "https://www.linkedin.com/in/dana-whitfield-example/",
  "note": "Hi Dana — we both worked on the OpenTelemetry collector. Would like to connect."
}

Confirm:

{
  "profileUrl": "https://www.linkedin.com/in/dana-whitfield-example/",
  "note": "Hi Dana — we both worked on the OpenTelemetry collector. Would like to connect.",
  "previewToken": "c1f3a08de4b7526a91d0e5748b2c63af",
  "confirm": true
}
  • profileUrl — required, non-empty. A full profile URL or a bare vanity slug.

  • note — optional, at most 300 characters. Notes over 200 characters need LinkedIn Premium on many accounts, so the preview warns above 200.

  • previewToken, confirm — as above.

Refused with invalid_input before touching your quota if the person is already a 1st-degree connection, an invitation is already pending, or the page exposes no Connect control.

linkedin_send_message (write — confirmation required)

Sends a direct message. Consumes the messages cap.

Preview:

{
  "profileUrlOrConversationId": "https://www.linkedin.com/in/dana-whitfield-example/",
  "text": "Thanks for the pointer to the collector RFC — that answered my question."
}

Confirm:

{
  "profileUrlOrConversationId": "https://www.linkedin.com/in/dana-whitfield-example/",
  "text": "Thanks for the pointer to the collector RFC — that answered my question.",
  "confirm": true
}
  • profileUrlOrConversationId — required, non-empty. Either a profile URL/slug, or an existing conversation thread id (as in /messaging/thread/<id>/).

  • text — required, 1 to 8000 characters.

  • previewToken, confirm — as above.

In profile mode the recipient must be a 1st-degree connection; anyone else is refused with not_connected before quota is spent. This tool never sends InMail, and it never presses Enter in the composer — it clicks Send.

linkedin_scrape_profile

Reads one profile and returns a structured Profile: name, headline, about, location, connection degree, whether it is your own profile, experience entries, education entries, and skills. Read-only.

{
  "profileUrl": "https://www.linkedin.com/in/dana-whitfield-example/"
}

Name and headline are checked twice. This tool once returned "Reactivate Premium: 50% Off" as both the name and the headline of a real profile, with empty experience, education and skills — LinkedIn had painted a Premium upsell card above the top card, the name selector chain ended in a page-wide a[componentkey] p, and the first match on the page won. Nothing was broken in a way any structural check could see: an element matched, a non-empty string came back, and every verifier went green.

So there are now three independent gates, and any one of them alone would have caught it:

  1. Scope. The primary candidates are anchored to the top card and require an /in/ href, so a promo card cannot satisfy them. The page-wide forms survive only as last-resort fallbacks.

  2. Text. Whatever a selector returns still has to be plausible as a member name or headline: upsell and ad copy (Try Premium, Cancel anytime, 1 month free, then US$39.99/month, a %-off or recurring-price phrase), navigation and button labels (Message, Connect, Follow, See all), profile status badges (Open to work, Hiring, Open to work · Recruiters only), a string with no letters in it, and a headline identical to the name are all refused, and the next candidate is tried.

  3. Corroboration. Surviving both of those still only makes a string a candidate. Before it is accepted it is judged against what the page says it is about — document.title, og:title, and the top card portrait's alt — and a candidate that a strong source names somebody else for is skipped and the next one tried. This is the gate for a perfectly plausible name read off the wrong card, which is the one thing neither of the others can see.

The text gate is deliberately conservative about product words in genuine copy — Principal Data Scientist at Ovelund Analytics · Premium member is a real headline and is kept, as is a growth headline mentioning $10M ARR. A false rejection deletes a real value, which is the harder failure to notice of the two.

The badge is why the text gate has to judge segments, not whole strings. With the upsell refused, the next thing above the genuine top-card anchor was LinkedIn's own open to work card — and it is structurally identical to the real one, an anchor with an /in/ href and a <p> inside a topcard component, so scope alone cannot tell them apart and only the text can. The first version of the label check compared the whole normalized string against a fixed list of UI labels, and Open to work · Recruiters only is not in any such list, so profile.name went green on a badge. isStatusBadgeText now splits on · and | and refuses a value whose every segment is a badge phrase or an audience qualifier, while leaving Staff Product Designer at Kolibri Labs · Open to work in Berlin alone — a genuine headline that mentions the badge has segments that are not badges.

Those four refusals — promotional, ui-label, status-badge, work-preference (the last one for a line like India | On-site · Hybrid · Remote, which a live run really did return as a name) — are the decoy family (DECOY_REJECT_REASONS). Both live validators treat them as a selector matching the wrong thing rather than as an absence, which is the distinction the earlier verifiers did not have.

If every candidate is refused, the tool keeps polling instead of accepting a decoy. The empty lists were a second, independent fault in the same call: the settle window was gated on the name being unresolved, so a page whose top card painted first and whose sections arrived later was read too early — and once the name was genuine, nothing waited at all. settleProfileSections now waits on evidence from Experience, Education or Skills before the "…see more" expanders run, regardless of how the identity resolved, and a profile that shows none of the three within the window returns a warning saying the empty lists are unconfirmed rather than proof the member has no history. From the DOM alone, "empty" and "not rendered yet" look the same; the tool says so instead of guessing.

The section families have to migrate together. LinkedIn's RSC cohort dropped the div#about / div#experience / div#education / div#skills anchors and now identifies each section by its heading text. That migration was applied per selector key, and one key was missed: aboutSection gained section:has(h2:has-text("About")) while about — the key a tool actually reads — kept only the id anchors and two legacy .pv-* classes. On that cohort the About section was therefore findable and its text was not, which is the most confusing shape a selector defect can take, because the container probe goes green. about now carries main section:has(h2:has-text("About")) … forms too, appended rather than substituted so the cohorts that still render div#about keep working, and confined to main because the About this profile overlay has an h2 reading "About" as well and renders outside it. A test pins the pairing itself: every section family whose container key identifies its section by heading text must have a content key that does too, so the halves cannot drift apart again. The three *Item keys were left alone — the run that exposed this found their headings absent from the DOM entirely, so there is no evidence about their shape on that cohort, and narrowing them on the strength of a miss is the blind replacement this project keeps refusing to make.

The strongest scope available is the profile's own URL. When the slug can be read from it, ownProfileLockups(slug) puts candidates anchored to the member's own /in/<slug> href ahead of the static ones — and matches the href with $=, not *=, so …/in/<slug>/details/featured/ and …/in/<slug>/recent-activity/all/ cannot satisfy them. Those sub-pages are the member's own links, a substring match accepts them happily, and that is precisely how a Featured card became the leading name candidate on a real profile. A slug that could break out of the selector string — a quote, a bracket, a comma, whitespace, a combinator, over 120 characters — is refused outright and the static topCardLockup is used instead; a scope you can inject into is not a scope.

Corroboration had a hole in one direction, and that Featured card fell through it. Agreement is tested on glued, case-folded keys in both directions, because og:title is "<name> - <headline>" and legitimately runs a long way past the name. The other direction — the candidate running past the source — had no bound, so a card titled NIKHILVARMA - LeetCode Profile glued down to nikhilvarmaleetcodeprofile, which starts with nikhilvarma, and the page was read as agreeing with an advert for the member's own coding profile. A name that leads with the right name is not the right name. That direction now allows six glued characters of surplus (MAX_NAME_SURPLUS) — room for a suffixed PhD or a middle initial, not for a second phrase — while the og:title direction stays unbounded.

When every candidate is contradicted, the page itself answers. That same profile renders no <h1>, no legacy details panel, and no <p> inside the member's own anchor, so once the Featured card is refused there is no better selector left to prefer — and failing the scrape of a page that plainly states whose it is would be the wrong answer. The reasoning already underneath gate 3 simply runs to its conclusion: if <title> is trusted enough to veto a selector's answer, it is trusted enough to give one after every selector has been vetoed. nameFromEvidence takes the name from <title> first, then from the top card portrait's alt, and from nothing else — not og:title, which would ship a headline glued to a name, and not the slug, which is deliberately too weak to refuse a name and therefore cannot be strong enough to supply one. If the two strong sources name different people, neither is used.

Three things happen on that path, and all three are in the response. The name comes back with a warning naming the source it was read from (the page <title>, the top card photo's alt text) and telling you to run npm run inspect:profile on the URL, because a selector still needs narrowing. The contradicted candidates come back as one warning, quoting up to five of the distinct values they returned, the count of matches behind them and the evidence that refused them — a recovered contradiction is still a selector defect, and swallowing it would let the wrong selector keep winning until a profile arrived with no <title>. And the headline is withheld: null, with a warning saying so. A headline is never corroborated by design, so the only thing standing between it and a decoy is the candidate order — and on this path every ordered candidate has just been proven to belong to something else, which makes the headline the one field left able to carry the original bug. null is honest; a plausible wrong string is not. verify:live is unaffected by any of this, because it probes the selectors directly rather than going through the tool, so profile.name still fails there until the selector is fixed.

One warning, not seventy. That contradiction warning was one-per-match until a real read-only run on a real profile emitted around seventy of them. Because the sentence names the value and the dissenting sources but never the selector, the duplicates were byte-identical — "161 profile views" six times, "Link" five — and the two warnings on that run an operator actually had to act on, the withheld headline and the three sections that never arrived, were somewhere in the middle of the pile. The values are now deduplicated and capped at five, the remainder is counted rather than dropped, and the match count is kept beside the distinct count because the gap between them is the measurement: one selector reaching too far reads differently from fifteen. The per-selector listing did not disappear, it moved to the log, which is where a full listing belongs. describeIdentityContradictions in src/validation.ts is pure and has its own tests, including one that reproduces the 70-match/30-value run and asserts the result is a single warning under 500 characters.

fixtures/profile-featured-link.html is that cohort, reproduced: bare <title>Nikhil Varma</title>, no og:title, an empty portrait alt, no <h1>, a Featured anchor reading NIKHILVARMA - LeetCode Profile, an analytics card reading Private to you, a company·school decoy pair, and the member's own anchor holding a portrait and no text at all. It is the only fixture on which every identity candidate is contradicted, and it fails if either half of this is removed — the bound on the prefix test, or the metadata fallback.

The same defect had a twin one field over, and it was reported as the member's own words. A read-only run returned about: "Client Engineer @IBM,Ex-Intern @IBM,@Cognida.ai" — that member's headline, handed back as their About section. SELECTORS.profile.about ends at main section:has(h2:has-text("About")) p, and :has() matches every ancestor section of the About heading, not the nearest one; on this cohort the outermost match wraps the top card as well, so its 53 <p> descendants begin at the top card and textOf takes the first. Every check passed: a selector matched, a non-empty string came back, and no text policy applies here at all — an About section is free prose, so there is no wording a member may not write about themselves and nothing that could refuse a job title as a paragraph. This is Reactivate Premium: 50% Off again with the one guard that caught it removed.

So the gate judges the value, not the selector. topCardEcho in src/selectors.ts compares the returned About text against every line the top card is known to have rendered — the resolved name and headline, the candidates corroboration contradicted, and the candidates the text policy refused — and if it matches one, about comes back null with a warning quoting the echoed line and telling you to narrow the selector. Note what that costs: on the fixture below the genuine About paragraph is right there in the markup, two <p> elements later, and it is still dropped. That is deliberate and it is the cheaper error, because the value being returned before was going out as the member's own words. The selector itself was not touched. All that a live run established is that two nested sections matched and the outer one holds the top card; it says nothing about whether the inner one holds the About text, and on that cohort the About body arrives over POST where no GET-only probe can see it. Narrowing on that much evidence would produce a plausible wrong string, which is the failure being fixed.

The echo test can be made circular, and topCardEvidence is what closes it. A name or headline candidate broad enough to sweep up the whole About paragraph gets refused for length — and offering that refusal back as proof inverts the argument: the About text matches a top-card candidate, therefore the About selector reached the top card, when what actually happened is that a top-card selector reached the About section. The genuine About text would be deleted for matching itself. So topCardEvidence withholds exactly the too-long refusals and passes the other eight reasons through. Length is the one refusal that is evidence of the opposite of what it looks like.

fixtures/profile-about-topcard.html reproduces the worst honest shape of that DOM: one <section> holding the top card and the About heading, with no inner section to scope to, no div#about, no .pv-about__summary-text, no .pv-shared-text-with-see-more and no div.inline-show-more-text inside it — so About candidates #1–#6 miss and #7 answers with a top-card line. No narrowing of the selector can rescue that fixture, which is the point: it exists to prove the value gate fires. The sweep asserts name and headline come back genuine, about comes back null, that it is neither top-card line, a warning names the echoed value, and the generic "No About section was readable" line is not also emitted — that would be a different claim about a different failure. verify:read-tools grew the matching check (auditAboutEcho), because it printed proven about 1/1 → profile.about on the run that shipped this: an About section byte-identical to name or headline in the same payload now prints as echoes the top card. It never fails a run — a member may paste their headline into their About box — but the fixed scraper cannot emit that shape at all, so seeing it also means the dist/ under test predates the gate.

Seven reports in, the broken half moved from the scope to the leaf. inspect:profile answers the one question the probe table cannot: take the name out of <title>, find every element in the body that holds it, and report what those elements are. On a real profile it answered an <h2>, inside a <div>, inside the anchor LinkedIn attaches to the name itself (componentkey="ProfileVerificationTriggerRef-<slug>", href="/in/<slug>/") — with no <h1> anywhere on the page. That anchor's href is the member's own profile, so the scope every candidate already had was correct; what was wrong is that all thirteen of them ended at a p or an h1, so not one could match a heading at any scope. The tool still returned the right name, from <title>, with No profile name selector survived on this page in its warnings — and headline came back null. A fallback answering is not a selector working: the payload looks right while the field is broken, and the six repairs before this one were all about chains that matched the wrong element rather than chains incapable of matching the right one. verify:read-tools grew the matching check (auditNameProvenance), because on that very run it printed proven name 1/1 beside the correct name: a name that arrived from page metadata now prints as not from a selector, naming the source the tool's own warning named, and adds a no selector line to the coverage summary. Like the About echo it never fails a run — where no name element is reachable the fallback is the only answer there is — but it is the difference between a report that is green and a report that is true.

The repair is three h2 leaves, and a gate on the headline. SELECTORS.profile.name now opens with three candidates that end at h2 — the top-card /in/ anchor carrying the same :not() chain as the <p> forms, the ProfileVerificationTriggerRef anchor by name, and the generic componentkey anchor form — and every previous candidate is kept behind them, in order, because a name in a <p> is what every other profile fixture in this repo renders. pickIdentity returns the first candidate whose text survives the policy, so prepending is the mechanism; appending would have changed nothing. The headline needed the opposite reasoning: once the name is a heading, the headline is the card's first paragraph, while every pre-existing headline candidate asks for p:nth-of-type(2) — which on that live card was the company·school line, and answered IBM · SRM University. No text rule may refuse that string, because there is nothing wrong with the words; it is simply not that member's headline. Three :has(h2)-gated candidates now sit at the head of SELECTORS.profile.headline, and the gate is the whole point of them: it keeps them off the <p> cohorts, whose nth-of-type(2) forms are correct and untouched. collectTexts try/catches each candidate, so if a Playwright build ever rejects :has() or ~ these degrade to a miss instead of breaking the read.

fixtures/profile-heading-name.html is that cohort, reproduced node for node: the name in an <h2> inside the member's own ProfileVerificationTriggerRef anchor, that anchor holding zero <p> (the inspector printed p 0 for it, which is a finding rather than an omission), no <h1> in the file, the genuine headline as the card's first paragraph and a company·school line as its second — delete that second paragraph "because it is redundant" and the fixture stops proving anything, because the wrong answer disappears with it. It also carries the trap the live inventory turned up on the way: [componentkey*="topcard" i] matches profileCardsAboveActivityTopcardOnly<slug> too, the Suggested for you / Private to you wrapper, and the lockup inside it is another member's — two clean lines, no chrome, name in a <p>. Nothing in Marit Solberg or Head of Ops at Bergen Freight is refusable as text, so the fixture names its own member in <title>, in og:title and in the portrait alt, and corroboration is what turns the decoy down. The key itself was not narrowed: a live paste proves that wrapper matches, not whether it wraps the genuine card, and excluding an ancestor would delete the genuine lines — the same reason SELECTORS.profile.about was left alone one section above. The inspector was fixed in the same pass, because it had the leaf list hardcoded as ['p','h1'] and would have gone on calling a repaired name unreachable: NAME_LEAF_TAGS in scripts/inspect-profile-dom.mjs now drives both the verdict and the sentence it prints, and a test derives the leaf set from the real SELECTORS.profile.name and fails if the two ever drift apart.

linkedin_scrape_feed

Reads recent posts from your feed and returns FeedPost entries: author name and headline, text, post URL, like and comment counts, and posted-at. Read-only.

{
  "count": 20
}

linkedin_search_jobs

Runs a LinkedIn job search and returns JobListing entries: jobId, title, company, location, whether it is Easy Apply, how strongly that is evidenced, and the job URL. Read-only.

{
  "keywords": "site reliability engineer",
  "location": "Berlin, Germany",
  "easyApplyOnly": true,
  "count": 25
}

keywords is required. Everything else is optional:

  • location — free-text place name, mapped onto LinkedIn's own location parameter.

  • easyApplyOnly — restricts results to Easy Apply postings (LinkedIn's f_AL facet) and holds the result set to that promise, which is not the same thing. See below.

  • datePosted"past24h", "pastWeek" or "pastMonth".

  • experienceLevel"internship", "entry", "associate", "midSenior", "director" or "executive".

  • remotetrue restricts results to remote roles.

  • count — how many postings to return (default 25, max 100). Results are lazy-loaded, so a large count means repeated scrolling with a randomized pause between each, and can take a while.

Those four facets may be passed either at the top level, as above, or grouped inside a filters object — both are accepted, and filters wins if you pass the same key twice:

{
  "keywords": "site reliability engineer",
  "filters": { "easyApplyOnly": true, "datePosted": "pastWeek", "remote": true },
  "count": 50
}

Cards that LinkedIn renders without a usable job id (promoted slots, placeholders) are skipped rather than returned half-populated, and counted in the skipped field of the response. The response also echoes the searchUrl it used, so you can open the identical query in your own browser.

Read easyApplyEvidence, not the easyApply boolean

A live search with easyApplyOnly: true once returned ten postings and reported easyApply: false on all ten. That output is self-contradicting — the tool claimed to have filtered to Easy Apply and claimed none of the results were — and a boolean cannot say which half is wrong. Two very different things had been collapsed into one field: LinkedIn showed no badge and this server could not find the badge.

So each posting now also carries easyApplyEvidence:

Value

Means

"badge"

An Easy Apply badge was seen on this card. Definitive; easyApply is true.

"none"

No badge was seen, and none was asked for. Nothing was checked, so nothing is claimed.

"facet-only"

No badge was seen, but the search carried f_AL=true, so LinkedIn is the one claiming this posting is Easy Apply. Unverified here. Treat easyApply: false on these as "not checked", not as "not Easy Apply".

And the response carries an easyApplyFilter report saying what became of the request — requested, facetApplied (did f_AL=true actually reach the URL), cardsRead, cardsWithBadge, droppedNotEasyApply, the badgeSelector to inspect when this goes wrong, and a verification:

  • "not-requested" — you did not ask for the filter. Nothing was enforced or claimed.

  • "no-results" — you asked, and the search returned no cards. That proves nothing about the badge, so it is not reported as a defect.

  • "verified" — every returned posting carries a badge.

  • "enforced" — the badge matched some cards, so it demonstrably works on this DOM, so the unbadged ones really do apply off-site and were dropped. droppedNotEasyApply counts them and a warning says how many and why.

  • "unverifiable" — the badge matched no card at all. Either LinkedIn ignored f_AL=true or SELECTORS.jobs.easyApplyBadge no longer matches a result card, and the search page alone cannot separate those. A loud warning names both possibilities and the selector.

In that last case the postings are kept, not dropped. This is deliberate: dropping them would turn a stale selector into "this query has no results", which is indistinguishable from an empty job market and is the failure mode most likely to waste your afternoon. Keeping them is safe because linkedin_apply_to_job looks for the Easy Apply button on the posting page itself and refuses anything else (not_easy_apply / external_application), so an unverified card cannot cause a wrong application. To find out which cause it is, run npm run verify:read-tools — it calls this tool against the real site and reports whether the badge produced anything.

linkedin_apply_to_job (write — confirmation required)

Submits a LinkedIn Easy Apply application. Consumes the jobApplications cap.

Preview:

{
  "jobId": "3912847561",
  "resumePath": "/Users/parthbansal/Documents/resume.pdf",
  "answers": {
    "Are you legally authorized to work in the US?": "Yes",
    "years of experience": "8"
  }
}

Confirm:

{
  "jobId": "3912847561",
  "resumePath": "/Users/parthbansal/Documents/resume.pdf",
  "answers": {
    "Are you legally authorized to work in the US?": "Yes",
    "years of experience": "8"
  },
  "previewToken": "c1f3a08de4b7526a91d0e5748b2c63af",
  "confirm": true
}
  • jobId — required. A bare job id, a /jobs/view/<id>/ URL, a search URL carrying ?currentJobId=, or a job urn all resolve to the same id.

  • resumePath — optional absolute path to a resume file on this machine. A missing file fails with file_not_found. The file's contents are never logged.

  • answers — optional map of question text to the answer to give. Keys are matched against the form's labels case-insensitively, first exactly, then by substring in either direction, so "years of experience" answers "How many years of experience do you have? *". Where two keys match the same question with different values, the longer, more specific key wins; a genuine tie is refused, not guessed (ambiguous-answer).

  • The preview lists every question on the first screen, and grades the screen: submittable is true only when nothing is blocking, blockers names each problem with a reason (unanswered, fill-failed, unchecked, missing-file, ambiguous-answer), and unansweredRequired is the labels alone. If blockers is non-empty, a warning tells you that confirming would be refused rather than submitted.

  • This tool never submits a partial application. Immediately before the irreversible Submit click, every required control is re-read from the page and checked against its real state — an unticked consent box, a dropdown still on its placeholder, a required upload with no file attached, an answer that was typed but did not take. Anything unsatisfied closes the modal, discards the draft, and fails with invalid_input naming each problem. A resume upload that the browser rejects abandons the application the same way, with browser_error and details.stage: "resume-upload" — it is never downgraded to a warning on an application that already went out.

  • Only the first screen can be previewed. Reading a later screen requires filling the current one in, which a preview must not do, so later steps may ask questions the preview could not show you. The pre-submit check above is what covers that gap.

  • Postings that hand off to an external applicant-tracking system fail with external_application; postings without an Easy Apply control fail with not_easy_apply. Both are decided during the preview, before a confirmation token exists, so neither costs you a daily application. Read the preview before confirming — it is your only chance to see which questions the form is about to answer on your behalf.

  • previewToken, confirm — as above.

linkedin_list_pending_invites

Lists pending invitations as PendingInvite entries: name, headline, profile URL, sent-at, and direction. Read-only.

{
  "direction": "received",
  "count": 25
}
  • direction — optional, "received" (someone invited you) or "sent" (you invited them). Defaults to "received".

  • count — optional integer, 1 to 100. Defaults to 25.

linkedin_list_connections

Lists your connections as ConnectionSummary entries: name, headline, profile URL, and connected-at. Read-only.

{
  "count": 50,
  "query": "observability"
}
  • count — optional integer, 1 to 200. Defaults to 50.

  • query — optional. A local, case-insensitive substring filter applied to the connections the server already read; it is not sent to LinkedIn as a search.

Both list tools drop a row they cannot read a name from, and both now say so. If every card on the page is dropped, the result is not silently an empty list: it carries a warning naming which half of the chain failed — the card selector matched, the name selector did not — telling you to open the page in your own browser and decide between two readings that have opposite fixes. If the rows show names there, the name chain needs narrowing. If they are still blank skeletons, LinkedIn had not delivered their contents yet and the fix is a longer wait, not a selector. The warning is deliberately forbidden from asserting either one, because a validation harness once read this exact state as "the connection list is empty" and blamed connections.connectionCard, a selector a live run had already proved matches.


MCP client configuration

The server speaks JSON-RPC over stdio and is meant to be launched by your MCP client, not by hand. Build first (npm run build), then point your client at dist/server.js.

Because the server resolves config.json, the state directory, and fixtures/ relative to its working directory — and your MCP client's working directory is usually not this project — it is worth setting the absolute paths explicitly in the env block.

Claude Code / Claude Desktop

In claude_desktop_config.json:

{
  "mcpServers": {
    "linkedin": {
      "command": "node",
      "args": ["/Users/parthbansal/Desktop/claude code/linkedin-mcp/dist/server.js"],
      "env": {
        "LINKEDIN_MCP_CONFIG": "/Users/parthbansal/Desktop/claude code/linkedin-mcp/config.json",
        "LINKEDIN_MCP_STATE_DIR": "/Users/parthbansal/Desktop/claude code/linkedin-mcp/.linkedin-mcp",
        "LINKEDIN_MCP_LOG_LEVEL": "info"
      }
    }
  }
}

Cursor

In .cursor/mcp.json:

{
  "mcpServers": {
    "linkedin": {
      "command": "node",
      "args": ["/Users/parthbansal/Desktop/claude code/linkedin-mcp/dist/server.js"],
      "env": {
        "LINKEDIN_MCP_CONFIG": "/Users/parthbansal/Desktop/claude code/linkedin-mcp/config.json",
        "LINKEDIN_MCP_STATE_DIR": "/Users/parthbansal/Desktop/claude code/linkedin-mcp/.linkedin-mcp",
        "LINKEDIN_MCP_LOG_LEVEL": "info"
      }
    }
  }
}

Generic stdio client (for example Codex CLI)

Any client that launches an stdio MCP server needs the same three things — a command, its arguments, and an environment:

{
  "name": "linkedin",
  "command": "node",
  "args": ["/Users/parthbansal/Desktop/claude code/linkedin-mcp/dist/server.js"],
  "env": {
    "LINKEDIN_MCP_CONFIG": "/Users/parthbansal/Desktop/claude code/linkedin-mcp/config.json",
    "LINKEDIN_MCP_STATE_DIR": "/Users/parthbansal/Desktop/claude code/linkedin-mcp/.linkedin-mcp",
    "LINKEDIN_MCP_LOG_LEVEL": "info"
  }
}

Codex CLI uses TOML rather than JSON, but the mapping is one-to-one: command = "node", args = ["/Users/parthbansal/Desktop/claude code/linkedin-mcp/dist/server.js"], plus an [env] table.

Safe experimentation

Add "--dry-run" to args to register a fully-functional server that cannot touch your account:

"args": [
  "/Users/parthbansal/Desktop/claude code/linkedin-mcp/dist/server.js",
  "--dry-run"
]

In dry-run mode no request reaches linkedin.com and no post, invitation, message, or application is ever submitted. This is the right way to let an agent explore the tool surface for the first time.

Other flags the server accepts: --read-only, --headless, --config <path>, --log-level <debug|info|warn|error>, and -h / --help (which prints to stderr, because stdout carries the protocol). Unknown flags are a hard error — a mistyped --dry-runn must never leave you believing you are in dry-run mode when you are not. Precedence throughout is command-line flags > environment > config.json > defaults.

Every environment variable is documented in .env.example; all of them are optional, and none of them hold credentials.

Read-only mode

--read-only is the other safety switch, and it is not the same promise as --dry-run:

reaches linkedin.com?

can change your account?

--dry-run

no — local fixtures only

no

--read-only

yes — the real site, real data

no

neither

yes

yes, after you confirm

Use it when you want an agent to read your actual LinkedIn — your real feed, your real connections, live job listings — with no possibility of a write. The four write tools (linkedin_create_post, linkedin_send_connection_request, linkedin_send_message, linkedin_apply_to_job) come back as read_only_mode errors; the other seven work normally.

Three details worth knowing:

  • The refusal happens before the tool runs, in the one wrapper every tool is registered through (src/server.ts), so there is no argument shape that slips past it and no per-tool check anyone can forget to add. All eleven tools stay listed — a client discovers the full surface and learns which ones are unavailable by calling them, rather than seeing a mysteriously short tool list.

  • Previews are refused too, not just confirm: true. That is deliberate and it is the non-obvious part: linkedin_apply_to_job builds its preview by opening the job and clicking Easy Apply to read the form, which LinkedIn can record as a started application. A bar that allowed "just the preview" would not have been read-only.

  • linkedin_login stays available. It writes storageState.json, so it is not readOnlyHint, but it changes nothing on LinkedIn — and refusing it would make read-only mode impossible to authenticate in the first place.

Set it however suits your client: --read-only on the command line, LINKEDIN_MCP_READ_ONLY=1 in the environment, or "readOnly": true in config.json. It defaults to off, so adding this feature cannot silently change an existing install. A malformed value (LINKEDIN_MCP_READ_ONLY=enabled) is a config_invalid error rather than a guess — guessing would be dangerous in either direction. Startup says so on stderr, twice, so the mode is never in doubt:

{"level":"warn","msg":"READ-ONLY: reads go to the real linkedin.com, but linkedin_create_post, linkedin_send_connection_request, linkedin_send_message and linkedin_apply_to_job will be refused with `read_only_mode`","readOnly":true,"refused":4}
{"level":"info","msg":"linkedin-mcp ready on stdio","version":"0.1.0","tools":11,"dryRun":false,"readOnly":true,"headless":false}

This mode is also what npm run verify:read-tools runs under — it will not start unless that second line reports readOnly: true and dryRun: false. That the refusal actually holds for valid write calls, and not merely for malformed ones, is checked separately by npm run verify:read-only.


Development

Type-check without emitting:

npm run typecheck

Run the unit tests:

npm test

Recompile on save:

npm run dev

--dry-run

npm run dry-run starts the built server with --dry-run. In that mode the browser is pointed at local HTML fixtures in fixtures/ instead of linkedin.com, and the final submit click of every write flow is skipped, so a confirmed action walks the entire code path — validation, quota check, dialog interaction — and then stops short of doing anything. Executed results carry dryRun: true, and previews warn you that dry-run is on. linkedin_login is unavailable, and linkedin_session_status always reports valid.

The fixtures cover the profile page (in three variants — a 2nd-degree profile with a direct Connect button, a 2nd-degree profile whose Connect sits behind the "More" menu, and a 1st-degree connection with a working message composer), seven more profile variants that exist only to attack what the top card returns (profile-promo.html, profile-open-to-work.html, profile-sdui-lockups.html, profile-decoy-pair.html, profile-featured-link.html, profile-about-topcard.html and profile-heading-name.html — the first five each a shape that made a real run return marketing, a badge, a company·school line, a plausible impostor, or a card whose title leads with the member's own name, as the member's name; the sixth the shape that made a run return that member's own headline as their About section; and the last the shape where the name is an <h2> no candidate could match at any scope, so the name arrived from <title> and the headline came back null), the feed, messaging, invitations, connections, job search in two variants (three cards of which two carry an Easy Apply badge, and — reached by searching for the keyword badgeless — three cards carrying no badge at all, which is the shape that drives the unverifiable Easy Apply verdict), and job detail pages in four variants: Easy Apply, an external applicant-tracking system, an application that cannot be completed (a required text left empty, a required upload with nothing attached, and a required consent box unticked), and one whose resume upload control always rejects the file. The last two exist so the never-submit-partial gate is exercised on every sweep. Develop here. There is no reason for day-to-day work on this server to touch your real account.

Which fixture a URL resolves to is decided by an ordered list of substring rules in src/fixtures.ts. The order is load-bearing — /mynetwork/invite-connect/connections/ contains /mynetwork/invit, and /in/tomas-eriksen contains /in/ — so specific rules sit above general ones and tests/fixtures.test.ts locks that ordering in place.

Verifying without LinkedIn

npm run verify:dry

This boots the compiled server in --dry-run, speaks JSON-RPC to it over stdio as a real MCP client would, and exercises all 11 tools across 39 cases — every read tool against its fixture, every write tool through a full preview → confirm handshake, the whole confirmation battery (a confirm: true with no token, a forged token, a legacy 16-hex digest, a token confirmed against changed arguments, and a replayed token that must not post twice), both Easy Apply filter enforcement paths (a search that drops the one unbadged posting, and a badgeless search that keeps all three postings and returns the unverifiable warning rather than pretending the result set is empty), and the other refusals that matter (an over-long post, a message to a 2nd-degree profile, an external job posting, an Easy Apply form with three unsatisfied required controls, a resume upload the browser rejects, linkedin_login under dry-run). One case is a regression guard for the identity defect described under linkedin_scrape_profile: fixtures/profile-promo.html puts two Premium upsell anchors above the top card and no <h1> anywhere, and the case asserts on the fields themselves — the genuine name and headline, a headline that still says "Premium member", and all three lists populated — because a whole-payload text probe would pass on a payload whose name was the advert. A second case guards the decoy that appeared once the upsell was refused: fixtures/profile-open-to-work.html stacks three impostors above the genuine anchor — the Premium promo, an open to work card whose text is one node reading Open to work · Recruiters only, and an anchor pointing at the member's own /in/ URL whose Open to work and All LinkedIn members sit in separate <p> elements, structurally indistinguishable from the real thing — and asserts the tool still returns Anselm Rothbauer with the headline that legitimately mentions the badge. A third case guards the defect that survived both of those: fixtures/profile-sdui-lockups.html reproduces the cohort a live run actually returned, where the top card is a stack of sibling cards that each satisfy the scoped selectors — the Premium upsell, an open to work frame reading Open to work · Recruiters only and India | On-site · Hybrid · Remote, a company·school line reading IBM · SRM University, then the member — and no <h1> anywhere, so no legacy fallback can rescue it. Judged line by line the frame wins, and judging the two fields independently answered them from two different cards (name from the frame, headline from the company·school line). The case asserts the tool reads each card as a unit and returns the member's own name and headline, which is what pickIdentityPair is for. Nothing about IBM · SRM University is refusable as text — both halves are real names — so the fix is structural, not another blocklist entry. A fourth case covers what structure alone still cannot settle: fixtures/profile-decoy-pair.html puts a decoy lockup whose two lines are both plausible — a name and a headline, in the right shape, in one container — above the genuine header, so the pair pass has two well-formed candidates and would take the first. The case asserts the tool answers with the member the rest of the page is about (<title>, og:title and the top-card portrait's alt all name them, and the decoy is corroborated by nothing), that neither decoy line appears as the name or the headline, and that the skipped candidate is named in a warning rather than swallowed — a contradiction means a selector is pointing at the wrong element, and only the operator can narrow that down. Delete the fixture's <title>/og:title/alt evidence and this case fails while every other identity case still passes, which is the whole reason it exists. That the viewer's own global-nav portrait — a different person entirely — is never read as evidence about the profile being viewed is pinned separately, in tests/browser.test.ts. A fifth case covers the cohort where corroboration refuses everything: fixtures/profile-featured-link.html has no <h1>, no og:title, an empty portrait alt, and a Featured card reading NIKHILVARMA - LeetCode Profile — a title that leads with the member's name and then keeps going, which is exactly what the unbounded half of the prefix test used to read as agreement. The case asserts the name comes back as Nikhil Varma from the page's <title>, that the headline is null rather than the second line of a card already known to be wrong, that none of the seven decoy strings on the page appears in either field, and that the warnings name the contradicted Featured title, the source the name was read from, and the withheld headline — while not also claiming the headline was unreadable, which is a different fact about a different failure. A sixth case moves one field over, to the same defect in the same live run: fixtures/profile-about-topcard.html puts the top card and the About heading inside a single <section> with no inner section to scope to and none of the containers its six earlier candidates need, so the last candidate — main section:has(h2:has-text("About")) p — answers with a top-card line, which is how a run came back with that member's headline as their About section. The case asserts the name and headline still come back genuine, that about comes back null rather than the headline, that it is neither top-card line, that a warning quotes the echoed value, and that the generic "No About section was readable" line is not also emitted, because a value that echoed the top card and a value that was never there are two different findings for the operator. No narrowing of the selector can rescue this fixture, which is exactly why it exists: it can only pass if the check is on the value. A seventh case is the only one in the sweep that asserts on how a field was answered rather than only on what it says: fixtures/profile-heading-name.html puts the name in an <h2> inside the member's own /in/ anchor with no <h1> on the page, and its <title>, og:title and portrait alt all name the member — so the metadata fallback alone would produce the correct string, and every value assertion would pass over a field that no selector reached. The case therefore also asserts the No profile name selector survived warning is absent, which is the only way to tell the h2 leaves working from the fallback covering for them, and it pins the headline to the card's first paragraph while checking the company·school second paragraph never appears in either field. The decoy member in the profileCardsAboveActivityTopcardOnly<slug> wrapper is refused by name in the same case, and Experience, Education and Skills are counted, because an identity resolved too early skips the settle window and the live run that reported this defect returned all three empty. It asserts the envelope contracts too: a preview must report executed: false and a 32-hex previewToken, a confirm must report executed: true, and stdout must carry nothing but JSON-RPC. One case reads counters.json on either side of a refused Easy Apply to prove that a refusal leaves the daily counter exactly where it was.

It refuses to run at all unless the server's own startup line confirms dryRun: true, so it cannot accidentally act on your account. It needs a working Chromium — everything but session_status opens a browser page — and it exits non-zero on any failure, sorting them into selector/logic failures (the server is wrong) and harness failures (the sweep is wrong).

Proving read-only mode refuses real write calls

npm run verify:read-only

A refusal that only holds for malformed input is not a refusal. tests/readOnly.test.ts calls the refusal function directly; this script spawns the compiled server with both --read-only and --dry-run and speaks JSON-RPC to it as a client would, sending each of the four write tools valid, schema-clean arguments in three shapes: bare, confirm: false, and confirm: true with a 32-hex token. Twelve calls, and each one must come back as read_only_mode naming the tool, with details.readOnly: true, a hint that says how to turn the mode off, no previewToken anywhere in the payload, and neither status: "preview" nor executed: true.

Then it checks what did not happen, which is the part worth having: stderr must contain no page opened record (no browser was launched), no dry run: serving fixture record (no fixture was served either — the refusal is earlier than that), no tool completed record, and exactly twelve tool refused by read-only mode records, three per tool. Afterwards it reads the temporary state directory and asserts counters.json was never created — twelve refused writes must charge nothing against the daily caps — and that no counters.json.lock was left behind. A browser-free control call (linkedin_session_status) confirms the server is refusing writes rather than everything.

--dry-run is on for belt and braces and is not the thing under test: if the refusal ever regressed, the tool underneath would drive local fixture HTML, could not reach linkedin.com, and the counter assertion would still catch it. Because nothing ever opens a page, this is the one verify:* script that needs no Chromium, which is what makes it runnable in CI.

Is dist/ actually built from src/?

npm run verify:dist

Everything above that says "spawns the compiled server" spawns dist/server.js, and dist/ is gitignored — so there is no diff to check it against, and a verifier can quietly pass against a build from three commits ago. This is not hypothetical: verify:read-only was first run against a dist/server.js older than the src/rateLimiter.ts it was supposed to contain. A green run of the wrong code is worse than a red one, because nobody goes looking.

So file times are the signal. Each .ts under src/ is compared against the exact file tsc emits for it, which lets a failure name the file (src/quota.ts is 6 minutes newer than dist/quota.js) instead of just declaring the directory stale. tsconfig.json and package.json are compared against the oldest emitted file, because those two change how everything compiles rather than one file — package.json earns its place through "type": "module", and the cost of it also tripping on a version bump is one npm run build. It reports missing for a source that was never compiled, and orphan for an emitted file whose source is gone: tsc overwrites but never deletes, so a renamed module keeps answering imports from dist/ until someone clears it, and that is the one kind of staleness a freshness check cannot see. Orphans are the only case where the suggested fix is rm -rf dist && npm run build rather than a plain rebuild.

It reads file metadata and nothing else — no network, no browser, no subprocess — and it never touches dist/ itself, so a failure is always cleared by a build you chose to run.

Continuous integration

.github/workflows/ci.yml runs on every push and pull request, on Node 20 (the floor in engines) and Node 24, and chains exactly the checks that need no LinkedIn session:

npm ci → npm run build → npm run typecheck → npm test
       → npm run verify:dist → npm run verify:read-only
       → npx playwright install --with-deps chromium → npm run verify:dry

verify:dist is close to a tautology there, since the build just ran; it sits in the chain because the guard itself then gets exercised on both Node majors, and because the step after it spawns dist/. verify:live and verify:read-tools are deliberately not in CI: both open the real linkedin.com with a real logged-in session, and putting one there would mean a LinkedIn cookie in a repository secret. They stay local-only.

Validating selectors against real LinkedIn

npm test proves the pure logic. npm run verify:dry proves the tools drive a page correctly — but against fixtures this project wrote. Neither can tell you whether a selector still matches today's LinkedIn. Only a live run can, and a live run against your own account is exactly where an accidental click is unacceptable. So:

npm run build && npm run verify:live

This opens allow-listed LinkedIn pages with your saved session, asks every selector chain "do you match anything visible?", and prints what it saw. It fills no field, submits no form, and clicks no button that starts a write.

Each page gets a detailed section — what was requested, what URL actually came back, what kind of page it was judged to be, and one block per selector:

── Home feed ──
   requested  https://www.linkedin.com/feed/
   observed   https://www.linkedin.com/feed/
   page kind  feed

  ✓ post.startPostButton — found
      matched: button.share-box-feed-entry__trigger
  ✓ feed.post — found (fallback #2 of 4)
      matched: div.feed-shared-update-v2
  ✗ feed.likeCount — not found
      verdict:  likely-empty-state (content layer)
      tried:    3 candidate(s)
        - span.social-details-social-counts__reactions-count  no match
      why:      …
      next:     …

  skipped on this page (9):
    – post.editor — inside a dialog that can only be opened by starting a write action

…followed by the flat one-line-per-selector rollup, aggregated across every page (found anywhere counts as found):

SELECTOR VALIDATION
✓ profile.name — found
✓ profile.headline — found
⚠ connections.connectionCard — matched an empty container (not validated)
✗ jobs.easyApplyButton — not found
· profile.connectButton — not present, and correctly so
~ feed.likeCount — unprovable under GET-only — prove it with `npm run verify:read-tools`
✓ messaging.messageBox — found

records which candidate matched and flags when it was a fallback rather than the primary hook.

is the one verdict worth explaining, because it is the failure mode this report exists to prevent. LinkedIn's newer pages paint a card's outer shell before (or without) its contents, so a container selector can match 36 elements on a page that holds none of the data those cards are supposed to hold. Every child selector probed inside such a shell then misses — and a naive reading blames those children. So a container that matched an element but contained none of the children the manifest expects inside it is reported as not validated, its children's misses are re-classified as "nothing can be concluded", and a required empty container fails the run exactly like a miss.

is not automatically a selector bug — every miss is classified (missing auth / wrong page state / empty container / blocked request / POST-rendered / legitimate empty state / refuted empty state / matched-the-wrong-element / DOM change), the candidates tried are listed with their match counts, and any equivalent element found on the page is dumped with its tag, role, aria-label, id, data-* and text so you can judge for yourself. The closing fixes block is split by how strong the evidence is: OBSERVED means an equivalent element was actually seen on the page and its attributes are printed for you; SUSPECTED means only that a selector produced nothing and no cheaper explanation applied — a "go and look in your own browser" prompt, never an instruction to rewrite a selector. The script never edits selectors.ts for you.

One verdict in that list means the opposite of absence. decoy-matched says a selector did match — and matched an advert, a button label or something else that cannot be the value it claims to be. This is the verdict that did not exist while linkedin_scrape_profile was returning "Reactivate Premium: 50% Off" as a member's name: an element matched, so profile.name was reported healthy and the run exited 0. It is deliberately the loudest verdict in the classifier — it outranks every excuse (a blocked-request page, a hollow container, an expected absence, an equivalent element found elsewhere), it is absent from EXPECTED_MISS_VERDICTS so nothing can excuse it, and on a required key it fails the run. It is also the only verdict that describes a tool succeeding: a miss makes a tool return nothing, a decoy makes it return the wrong thing, and nothing downstream notices.

The two identity keys — profile.name and profile.headline, the pair listed in IDENTITY_PROBE_KEYS — therefore print what they read, and what they refused on the way there:

  ✓ profile.name — found
      matched: main [componentkey*="topcard" i] a[href*="/in/"] p:first-of-type
      text:    "Anselm Rothbauer"
      skipped: "Reactivate Premium: 50% Off" (promotional)
      skipped: "Open to work · Recruiters only" (status-badge)
      identity: corroborated by title, og-title, photo-alt

The skipped: lines are the impostors the chain stepped over before reaching the member; three are shown and the rest are counted. They do not change the verdict — the value that was ultimately picked decides it — but a green identity row that skipped two decoys and a green identity row that matched on the first try are different states of the site, and the report should not flatten them into the same tick. The validator gets all of this by importing the server's own compiled collectTexts, pickIdentity and firstDecoy from dist/, so it cannot drift from the scraper it is validating. When every candidate is refused, pickIdentity returns nothing, and the key is reported as decoy-matched rather than found.

The identity gate: text: is a claim, identity: is the check

The skipped: gate above answers one question — could this string be a name? It cannot answer the question underneath it: is it this member's name? On the profile that produced this bug the same chain, once the upsell was refused, went on to return "Client Engineer @IBM,Ex-Intern @IBM,@Cognida.ai" as the name and "IBM · SRM University" as the headline. Both are text-acceptable, and that is not a gap in the policy — it was confirmed by execution that identityTextRejection returns null for "IBM · SRM University", because there is nothing wrong with the string. No blocklist can catch a plausible name read off the wrong card; a plausible string can only be checked against something outside the element that produced it.

So on every profile stop the run first reads what the page says about whose profile it is, from four independent sources: the URL's /in/ slug, document.title, og:title, and the alt of the top card's portrait. All four are read with textContent and getAttribute on selectors — no page.evaluate, no click, nothing that could trip the non-GET kill switch — and every failure is swallowed to null, because missing evidence has to read as silence rather than as a finding. They are read once per stop and before the first probe, so a late render cannot change what "this page" means half way down the manifest, and only on profiles: document.title on the feed is "Feed | LinkedIn", which would dissent from every name on the page and turn a healthy run red.

profile.name's accepted value is then judged against that evidence by identityCorroboration in src/validation.ts, and one of four verdicts is printed under the row:

verdict

printed

effect

corroborated

identity: corroborated by title, og-title, photo-alt

Nothing to chase. Any source agreeing is enough — a name that og:title leads with is this member's name whatever else the page renders.

contradicted

the row leaves the path entirely; see the wrong: / page says: block below

A strong source (title, og-title, photo-alt) names somebody else.

suspect

identity: not corroborated — … plus the dissenting readings

Only the slug dissented. Reported, never fatal.

unavailable

identity: no independent evidence on the page, either way

Nothing usable was found. Dim, not yellow.

The first, third and fourth print under a row that passed — the identity: line is the one thing separating "a selector matched and something plausible came out" from "and the page agrees it is the member". A with no identity: line beneath it is a name nothing checked.

A contradiction does not soften a green row, it removes it. The row is re-run through the ordinary miss ladder carrying identityMismatch, which classifies as identity-not-corroborated at the selector layer — and like decoy-matched, that verdict is deliberately absent from EXPECTED_MISS_VERDICTS, so no blocked request, hollow container or empty-account excuse can explain it away. On a required key such as profile.name, the run exits 1:

  ✗ profile.name — matched a plausible value that belongs to someone else
      verdict:  identity-not-corroborated (selector layer)
      wrong:    main [componentkey*="topcard" i] p:first-of-type
      text:     "Client Engineer @IBM,Ex-Intern @IBM,@Cognida.ai"
      page says: title = "Parth Bansal | LinkedIn"
      page says: photo-alt = "Parth Bansal"
      tried:    4 candidate(s)
      why:      …
      next:     Scope `SELECTORS.profile.name` to the lockup that owns the member …

wrong: / page says: are worded deliberately unlike the decoy: / refused: block above them, and the difference is not cosmetic. A decoy is refused on the strength of the string"Reactivate Premium: 50% Off" is not a name on any page — and the remedy is the text policy in selectors.ts. A mismatch is refused on the strength of the page: the string is a perfectly good name and simply is not this member's, and the only remedy is a narrower selector. Printing both with the same labels would send you to fix the wrong file, so identity-not-corroborated names the lockup to scope to (the one whose anchor is this profile's own /in/ href) and tells you to confirm with npm run inspect:profile before editing anything.

Only contradicted fails. suspect prints yellow under a row that still passes, because a slug is free-form: /in/thecodingguy is a legitimate URL for a member named Anita Desai, and a validator that failed on nicknames would be switched off within a week — which would cost more than it catches. unavailable is dim rather than yellow for the same reason in reverse: a profile can legitimately render without an og:title, and colouring silence as a warning teaches an operator to ignore the column that will one day be red for a real reason.

Two limits are on purpose. profile.headline is never corroborated — a headline is not supposed to be the member's name, so agreement there would be evidence of a bug, not of health; the headline is tied to the name a different way, by pickIdentityPair requiring both to come from the same top-card lockup. And the viewer's own portrait in the global nav (img.global-nav__me-photo) is excluded from the alt candidates, which are all scoped to main: on your own profile it agrees with everything, and on anyone else's it contradicts the correct answer. A ground-truth source that is only right when it does not matter is worse than none.

This is the check with access to the strong evidence. verify:read-tools sees only the JSON a tool returned, where the profile URL is the sole piece of ground truth available — so it prints not corroborated / unconfirmed and never changes its exit code, and points here to settle it. The two gates are complementary, not redundant: this one can read the page and fail on it; that one can see values this one cannot prove under GET-only. Both printers here are pure and return arrays rather than printing, so tests/liveIdentityGate.test.ts asserts the wording without a browser — the corroboration pass exists because a run looked green while the tool returned an advert, and a printer that quietly dropped this block would leave the report looking exactly as green as the one that shipped the bug.

· is a miss the page itself accounted for, collected into a closing EXPECTED ABSENCES block. Your own profile carries no Connect button, no Message button and no connection degree — those controls mean nothing pointed at yourself. A 1st-degree profile carries no Connect control anywhere, overflow menu included. An inbox with no thread open has no composer. A posting that applies off-site has no Easy Apply button. Each of these is what a healthy account looks like, so each is printed with the observation that excused it and excluded from the required-miss count. Four manifest keys are both required and conditional on exactly this kind of page state — profile.messageButton, jobs.easyApplyButton, messaging.messageBox, messaging.sendButton — and counting them as failures used to make verify:live exit 1 on an account with nothing wrong with it. An exit code that is always 1 is an exit code nobody reads, which is worse than not checking. The excuses are an allowlist (EXPECTED_MISS_VERDICTS in src/validation.ts), so a verdict added later fails the run until someone justifies it there.

Two things · deliberately does not do. It never excuses a Connect miss on a 2nd- or 3rd-degree profile — that is exactly what a renamed Connect button looks like, and it reaches you as a finding. And it never accepts "a Message button is present, so we must already be connected" as evidence: on a 2nd-degree profile with InMail credits, Message renders alongside Connect, so that inference would happily swallow a real DOM change. Only what the page stated outright counts.

There is a third thing it does not do any more, and the hole was found the same way every other one in this report was — by reading what the run printed underneath its own verdict. A real read-only run reported jobs.easyApplyBadge like this, and counted it under expected absence 7 (the page proved these could not be there):

  · jobs.easyApplyBadge — not present, and correctly so
      verdict:  expected-empty-state (page-state layer)
      why:      `jobs.easyApplyBadge` is conditional: Only on Easy Apply postings. A search built with `f_AL=true` should show it on most cards, so a total miss there is suspicious.

Nothing proved absence. The key is gated conditional because most postings apply off-site, classifyMiss fell through to that gate, and the sentence it printed to justify the verdict is the sentence that argues against it — this run built its search with f_AL=true precisely so the Easy Apply hooks would mean something. So the gate is now refutable: easyApplyFacetContradiction (src/validation.ts) hands classifyMiss the two observations that dismantle it — the search asked LinkedIn for the Easy Apply facet, and LinkedIn returned result cards for that request — and the miss becomes condition-satisfied, a yellow with a "go and look in your own browser" recommendation under RECOMMENDED FIXES → SUSPECTED. It is the mirror image of the expectedByPageEvidence pass above it: same three-line shape in scripts/validate-live.mjs, same rule that only direct observations count, opposite conclusion. Both halves of the evidence are required — an empty result list has nothing to carry a badge, and an unfiltered search is mostly off-site postings — and the facet must have survived the redirect, because if LinkedIn dropped it the cards were never filtered and there is nothing to contradict.

condition-satisfied is deliberately not on the EXPECTED_MISS_VERDICTS allowlist, which is what makes it print as a finding instead of an excuse; the run still exits 0, because missFailsRun needs the key to be required and jobs.easyApplyBadge is not. That is the whole intended severity: a line the operator reads, not an exit code on a healthy account. Adding the verdict broke the allowlist's own exhaustive test until it was written down there — which is the guard working as designed.

The sibling key jobs.easyApplyButton is held to a weaker standard on purpose, and the report says so out loud rather than leaving the gap looking like an oversight. It lives on the job-detail page, and the only reason to believe that posting is Easy Apply is that LinkedIn auto-selected it from the filtered search — an inference about currentJobId, not an observation. It is also required: true, so a wrong call there would exit 1 on an account with nothing wrong with it. EASY_APPLY_FACET_KEYS therefore contains exactly one key, a unit test pins it to that one key, and any job-detail miss prints EASY_APPLY_BUTTON_CAVEAT explaining why it was let off.

The same shape came up a third time on /messaging/, and it was refused too. A live run missed both messaging.messageBox and messaging.sendButton with what looked like a real thread selected, and both were excused expected-empty-state on the strength of the gate's own note — "an empty inbox is the usual reason for a miss" — which that run's evidence did not support. Making it refutable needs an observation that a conversation was open, and there isn't one: observedUrl is snapshotted once before any probe while LinkedIn selects the newest thread client-side after hydration, so a /messaging/thread/<id>/ URL may simply not be written yet; messaging.threadItem proves the inbox is non-empty, not that a thread is selected; and every composer candidate keys on msg-form, so nothing probes the thread pane independently of the composer it is trying to judge. Both keys are also required: true. So the verdict stays expected-empty-state, the run still exits 0, and MESSAGING_COMPOSER_CAVEAT prints underneath it — including the one piece of evidence that argues against editing anything: candidate [role="textbox"][contenteditable="true"] is broad enough to match any contenteditable region on the page, and it matched nothing. That points at no thread pane having rendered, not at a renamed msg-form. It is the box linkedin_send_message types into, so the note tells you to open a thread and look.

~ is the honest admission in this report. LinkedIn serves some pages — the feed, profiles, invitations, connections — from an RSC payload that arrives over a POST, and safety layer 2 aborts every POST before it leaves the machine. Those selectors therefore cannot match here, no matter how correct they are. Rather than print them beside real misses, where they read as 36 simultaneous DOM changes, they are pulled into their own UNPROVABLE UNDER GET-ONLY block, excluded from the missed count, and pointed at the one thing that can prove them: npm run verify:read-tools (below). The fix is never to widen the request filter.

means the element only exists inside a dialog that opens by starting a write, or only after one completes; unreachable read-only, so it is reported in a closing NOT VALIDATED block rather than hidden.

Exit codes: 0 = every required selector this run could actually test was found, 1 = a required selector missed for a reason the run could not excuse, or matched only an empty container, 2 = the run never happened (no build, no session, a challenge, a launch failure, or a card-scoped candidate that names its own container and therefore cannot match any DOM — see conventions; that one is a defect in this repo, so the run stops with the list rather than blaming LinkedIn for it).

Three kinds of miss cannot produce exit 1, because in each case the run had no way to observe the element:

  • Not required. Reported, never fatal — most are legitimate empty state.

  • ~ POST-rendered. The content arrives over a request the safety layer aborts, so the run had no way to see it and could not honestly call it missing. That is what makes the pairing below load-bearing rather than optional: verify:read-tools is where those keys can actually fail. profile.name and profile.headline were once on that list and are not any more. A GET-only run on a real signed-in session found both — the name at the h2 leaf inside the top card, the headline at the third candidate — on a page where 439 non-GET requests were aborted, which refutes the claim that either needs a POST to arrive. Keeping the name there would have left a hole in the gate: a total miss on the one selector this project calls its most damaging failure would have classified as an expected verdict, and the run would still have printed required miss 0. It now classifies blocked-requests, which fails. The cost is stated rather than hidden: on a cohort where LinkedIn genuinely defers the name, a legitimate GET-only miss now fails the run and has to be dismissed by a human instead of being waved through silently.

  • · expected absence. The page proved the element could not have been there. --profile <url> is how you turn the four other-member keys from an expected absence into a real test.

An empty container is the one case that still fails while found is technically true: a required is unvalidated, and unvalidated is not the same as fine.

Flags:

flag

effect

--plan

Print exactly what would be probed and exit. Opens no browser, sends no request. Start here.

--profile <url>

Also validate against another member's profile. Without it, profile.connectButton, profile.connectInDropdown, profile.messageButton and profile.connectionDegree are skipped rather than probed on your own profile — they cannot exist there, so a miss would be noise. This flag is the only way to test them. It has to be another member: if the URL names the account you are signed in as, the run says so and skips the stop rather than loading your own profile a second time and reporting four misses that were never possible. See Pointing --profile at yourself.

--job <id|url>

Validate jobs.easyApplyButton and the rest of the job-detail keys against a posting you know is Easy Apply, instead of whatever the keyword search happened to return first. Accepts a bare id (4012345678), a /jobs/view/<id> URL, a ?currentJobId=<id> search URL, or a urn:li:jobPosting:<id> urn — parsed by the same extractJobId the real tool uses, so anything this accepts the tool would accept too. The posting is only read; Easy Apply is never clicked. Supplying it also switches off the discovered-job stop, so the run probes the posting you vouched for and nothing else.

--keywords <text>

Job-search keywords (default "software engineer").

--location <text>

Job-search location (default: none).

--only <kinds>

Comma-separated subset of feed,profile,jobs-search,job-detail,messaging,invitations,connections.

--probe-menus

Open the profile "More" overflow menu so profile.connectInDropdown can be probed. Off by default: even a read-only click is an interaction. Ignored on your own profile, where there is no Connect entry to find.

--headless

Run Chromium headless. Default is headed, so you can watch it.

--screenshots

Save a local screenshot of each page visited. Local only, never uploaded.

--json <path>

Write the full machine-readable report to a file.

The safety posture is five independent layers, each sufficient on its own:

  1. No write tool is imported. The script does not load dist/registry.js and cannot invoke a write tool even by accident. It also never uses a tool's preview half — see the read-only note above for why linkedin_apply_to_job's preview is not safe.

  2. Route-level kill switch. Every request is inspected; anything not GET/HEAD/OPTIONS is aborted before it leaves the machine, as is any URL carrying a state-changing marker — logout above all, because LinkedIn's sign-out is reachable by a plain GET and a naive "GET is safe" rule would destroy the very session you asked to verify.

  3. Navigation allowlist. Every goto target must pass isAllowedValidationUrl. An unrecognized LinkedIn path is refused rather than assumed harmless.

  4. No clicking. The only click available is opening the profile overflow menu, under the explicit --probe-menus opt-in. Connect / Send / Submit / Apply are never even located as click targets.

  5. Challenge hard stop. A CAPTCHA, checkpoint or auth wall ends the run with instructions to clear it in your own browser. Nothing is solved, bypassed, or retried.

It needs a session it did not create: if storageState.json is missing or LinkedIn no longer accepts it, the script stops and tells you to run linkedin_login yourself. It never types a credential. It also refuses to run when dry-run is configured — validating fixtures against fixtures would prove nothing.

One privacy note: both --screenshots and --json write files containing real content from your account — page images, profile text, aria-labels. Nothing uploads them. Screenshots land in the gitignored state directory (.linkedin-mcp/screenshots/), but the --json path is wherever you point it, so choose it deliberately or keep it inside the state directory.

The pure logic underneath it (the selector manifest, the page classifier, the URL allowlist, the miss classifier) is exported from src/validation.ts and covered by tests/validation.test.ts, so the safety rules are provable without a browser.

Pointing --profile at yourself

--profile exists to reach the only four keys your own profile cannot have. Handing it your own URL therefore asks for something impossible, and it used to fail in a way that looked like four broken selectors: the run visited the same page twice and reported seventeen identical misses, one of them (profile.messageButton) required, so a completely healthy account exited 1.

The run now recognises it. /in/me/ names no member, so the check cannot happen up front — only the redirect knows your slug. Once the own-profile stop has resolved, any later --profile stop is compared against where it landed:

skipping other member profile (https://www.linkedin.com/in/you/) — that is the
member this session is signed in as, so the stop is the own-profile stop again.
Connect, Message and the connection degree cannot exist on your own profile —
pass another member’s URL to validate them.

Caught before the navigation, so a duplicate costs your account nothing: no second page load, no probes, no misses. Comparison is slug-only and case-folded (isSameProfile in src/selectors.ts), because the URL a profile navigation lands on routinely differs from the one you typed — extra query parameters, a trailing slash, sometimes different case. --plan says the check exists but cannot yet run it, for the same reason: no browser, no redirect, no slug.

Proving the selectors verify:live structurally cannot

npm run build && npm run verify:read-tools

Safety layer 2 above is a hard kill switch: every non-GET request is aborted. That is the layer worth keeping — but it has a consequence. LinkedIn's feed, profile, invitations and connections pages fetch their actual content over POST /flagship-web/rsc-action/…, so under verify:live those pages render a shell and 36 selectors miss for a reason that has nothing to do with whether they are correct. No amount of re-running fixes that, and the obvious "fix" — allowing those POSTs — would trade a real safety guarantee for a diagnostic convenience.

This harness proves them the other way round, at no new safety cost. It boots the compiled server with --read-only and calls the five read tools you already use over stdio:

tool

what its output proves

linkedin_scrape_profile

profile.name, headline, about, locationText, connectionDegree, experienceItem, itemTitle/itemSubtitle/itemDateRange/itemDescription, educationItem, skillItem

linkedin_scrape_feed

feed.post, authorName, authorHeadline, postText, timestamp, permalink, likeCount, commentCount

linkedin_search_jobs

jobs.resultCard, cardTitle, cardCompany, cardLocation, easyApplyBadge

linkedin_list_pending_invites

invitations.inviteCard, inviteName, inviteHeadline, inviteProfileLink, inviteSentAt

linkedin_list_connections

connections.connectionCard, connectionName, connectionHeadline, connectionLink, connectedAt, resultsCount

The reasoning is simple: if linkedin_scrape_profile comes back with a name, a headline and three job titles, then the selectors that read them matched real DOM on the real site. A field that comes back empty on a page that did return records is the actual signal — that is a selector to look at.

That reasoning has one hole, and it is the hole this project fell into: something came back is not the same as the right thing came back. A page-wide selector that matched a Premium upsell returns a perfectly populated string, and counting it made this harness report profile.name proven and exit 0 on a release-blocking bug. So the two fields that carry a member's identity — name and headline — are measured against the same text policy the scraper applies (identityTextRejection in src/selectors.ts, so the two can never disagree about what counts as a name). A value that fails it counts as not populated, is printed as wrong value rather than shape, and fails the run:

  wrong value  name: "Reactivate Premium: 50% Off" is promotional copy (an upsell or ad), not a member name

shape and wrong value have to stay distinct in a report: the first means this harness needs updating, the second means the tool is returning wrong data. An absent name stays an emptiness question rather than becoming a content one, because "LinkedIn changed the DOM" and "the tool returned an ad" have different fixes.

The text policy closes only half of that hole. It can refuse marketing and UI labels; it cannot refuse a plausible sentence read off the wrong card — the same account later came back with "Client Engineer @IBM,Ex-Intern @IBM,@Cognida.ai" as its name, and no text rule can call that wrong, because for somebody it would be right. So every name this harness receives is also judged against the profile URL it arrived with (auditIdentityAgainstUrl in src/validation.ts, running the same corroboration policy verify:live runs on the page). A slug that leads with the name corroborates it. A slug that does not is reported and never failed/in/thecodingguy is a legitimate slug for a member named Anita Desai — and an opaque one like /in/pb-4a5b6c7d says nothing at all rather than guessing:

    proven       name                        1/1 → profile.name
      value name = "Client Engineer @IBM,Ex-Intern @IBM,@Cognida.ai"
        identity: not corroborated (name)
        url says:  /in/parthbansal99
        value:     "Client Engineer @IBM,Ex-Intern @IBM,@Cognida.ai"

Names that do match are counted on one line (identity: 3 names match the profile URL) so twenty green lines cannot bury the one yellow one, and the run summary carries a caveat beside the four coverage verdicts rather than a fifth one:

  unconfirmed   1 returned name(s) the profile URL does not back up

linkedin_list_pending_invites and linkedin_list_connections are audited the same way, each row against its own link — never against the URL the call asked for, which belongs to the account holder and would invent a mismatch on every card. This harness only ever sees the JSON a tool returned, so a slug is the strongest evidence available to it; <title>, og:title and the top-card portrait's alt are on the page, which is what npm run verify:live -- --profile=<url> reads.

The same run reported proven about 1/1 → profile.about while about held that member's headline, and no text policy could have caught that one either — an About section is free prose, so nothing may refuse one on its wording. What the payload still says is whether about came back identical, normalized and case-folded, to name or headline beside it (auditAboutEcho in src/validation.ts, deciding with the same topCardEcho the scraper's own gate uses, so the two cannot drift about what counts as an echo). It prints beside the proven row and adds a second caveat to the summary:

  echoed        1 About section(s) identical to a line from the same top card

Reported, never failed, for the same reason a dissenting slug is: a member may legitimately paste their headline into their About box. But the fixed scraper drops that value and returns about: null, so an echoed row also means the dist/ under test predates the gate — rebuild before going selector-hunting.

name has the same shape of hole one level up, and it is the one this harness reported green the longest: a later run printed proven name 1/1 beside value name = "Parth Bansal" on a page where no candidate reached the element holding it. The name was an <h2>, every candidate as written ended at a <p> or an <h1>, and scrape.ts answered from the page's own <title> instead. The value is right; the verdict is not. A fallback answering is not a selector working, and nothing in the string can separate the two — a name read from <title> and a name read from the top card are the same characters. The provenance survives in one place only, the warning the tool raised about its own result, so that is where auditNameProvenance (src/validation.ts) reads it: keyed on the scraper's own No profile name selector survived on this page sentence and on the four source labels it can name, so the audit and the warning cannot drift about which source answered.

    proven       name                        1/1 → profile.name
      value name = "Parth Bansal"
        not from a selector (name)
        value:     "Parth Bansal"
        read from: the page metadata (title)

The row above it stays proven and the run still exits 0. Calling it empty would assert something false — records did come back and one did carry a name — and would fail a run on a payload that is correct. Where no name element is reachable, the fallback is not a workaround but the only path to an answer, and the tool named it rather than hiding it. What changed is that the report now says both halves out loud, and the coverage summary carries a third caveat beside the other two:

  no selector   1 name(s) read from page metadata, with no candidate reaching the card

That caveat is the line to act on, and it points at one command: npm run inspect:profile -- --profile=<url> reports which element actually holds the string and whether any candidate can end there. It is also how a repair is told apart from a cover-up. With the three h2 leaves now at the front of SELECTORS.profile.name, a healthy run on that same profile prints the proven row with no not from a selector block beneath it — and npm run verify:dry asserts that on the fixture by requiring the No profile name selector survived warning to be absent, which is the only difference between the leaves working and the fallback covering for them.

Why this is not a new hole in the safety posture:

  • These five tools never click a write control. Not in preview, not in confirm — there is no write path in them to reach. They are the same code paths you invoke from your MCP client every day.

  • No new exception to the kill switch. The harness does not touch it. It runs the server normally, which is where those POSTs were always allowed.

  • Read tools consume no daily quota. The caps in RateLimiter cover connectionRequests, messages, posts and jobApplications only, so a validation run cannot eat into your posting or connecting budget.

  • Six gates run before any page work, in this order: the call plan is checked for a mutating tool, a confirm/previewToken argument, an out-of-range count and a non-https profile URL; scopedSelfReferences() must be empty, because a card-scoped candidate that names its own container cannot match any DOM and would be reported as a LinkedIn change that never happened (details); the server's own startup line must report readOnly: true and dryRun: false; every planned tool must exist in tools/list and advertise annotations.readOnlyHint: true; and linkedin_session_status must come back valid. A verification_required anywhere is a hard stop — no challenge is ever solved, bypassed or retried.

It needs a real signed-in session (run linkedin_login first) and it will not run under dry-run — fixture data would prove nothing about live selectors. Output is per-field, with the manifest keys each field carries:

profile (your own)   linkedin_scrape_profile
  records 1 · page profile
    proven       name                        1/1 → profile.name
    proven       experience[]                1/1 → profile.experienceItem
    proven       experience[].title          3/3 → profile.itemTitle
    empty        experience[].description    0/3 → profile.itemDescription
      note: many roles genuinely have no description
    no-records   skills[]                    0/0 → profile.skillItem

proven = at least one populated value, so the selector matched. empty = records came back and not one carried this field — this is the finding, and the manifest's own note on that field is printed beside it so you can weigh "the selector broke" against "nobody filled this in". no-records = the collection was empty, so nothing was observable (a genuinely empty invitations list reads this way, which is why it is not counted as a failure). unsettled = a card selector matched nothing on a page with no rows on it, and this run has no second instrument to say whether the page was empty or the selector is dead — a lead, never a verdict. not-observed = the tool did not run, so nothing was measured. Anything no read tool reads at all — feed.seeMoreButton and the four profile section containers, five keys in total — is listed separately as hand-check-only rather than left as a silent gap in the coverage claim.

no-records is the only benign verdict, so it is held to a number rather than taken on trust. A run had linkedin_list_connections find ten cards, fail to read a name out of a single one of them, drop all ten, and return connections: [] — and because the count of returned records was zero, every connections.* key was filed "inconclusive — nothing to observe", which reads as you have no connections. The same run's own warning said ten cards were on the page. Each list tool already publishes what it saw before extraction (cardsLoaded, or total for the invitation manager), the evidence table names that property, and zero records out of a non-zero count is now reported as empty and fails the run. The header line says which of the two it was, so an emptied list and an empty account no longer print the same thing:

connections          linkedin_list_connections
  records 0 of 10 rows on the page — every row was dropped, not an empty account
    empty        connections[].name          0/0 → connections.connectionName

A tool that publishes no count keeps the old behaviour — silence is not evidence — and a partial drop (8 records of 10 rows) proves the fields that did come back rather than failing on the two that did not.

One key is exempt from that number, because for it the number is not a second opinion. Every row count is the match count of the tool's own card selector: feed cardsLoaded counts feed.post, connections cardsLoaded counts connectionCard, invites total counts inviteCard. That selector is also the collection's own container field (connections[]), so the count is direct evidence about that one key rather than corroboration of it — and the first version of the fix above got both halves of that wrong. Ten rows and zero records reported connectionCard as empty, accusing the one selector the count proves matched ten elements; and zero rows with zero records filed it under no-records, "nothing was there to observe", which is circular, because the container matching nothing is the cause of the zero rather than an excuse for it. That is how feed.post matching nothing on a signed-in feed read as a quiet day. So the container (containerFieldName in src/validation.ts) is decided before either of those branches: rows on the page prove it, and no rows leave it unsettled.

unsettled never fails a run, and the report says why in the same words: an empty invitation inbox is indistinguishable from a dead inviteCard from here, so failing on it would fail most accounts. It gets its own section instead, above inconclusive because that is where its evidence sits — the selector did run against the page, which inconclusive cannot say, and nothing corroborated the zero it returned, which empty requires. The section points at npm run verify:live, which prints every candidate it tried and is the only thing that can settle it.

Every identity value is printed verbatim, and so is every warning the tool raised about its own result. Neither used to be: the payload was dumped only under --verbose, so the run that reported profile.name proven while the tool was returning "Reactivate Premium: 50% Off" left no trace in its own report of what it had measured. A verdict you cannot read is a verdict you have to trust.

    proven       name                        1/1 → profile.name
      value name = "Anselm Rothbauer"
    proven       headline                    1/1 → profile.headline
      value headline = "Staff Product Designer at Kolibri Labs · Open to work in Berlin"
  warned  Neither Experience, Education nor Skills was present in the page after waiting…

Values are whitespace-collapsed and clipped to 120 characters, and at most three are shown per field. A value the text policy refused prints as refused in red with the reason underneath it instead of value — the same string the wrong value line reports, from the same call, so the two halves of the report cannot disagree (a test asserts exactly that).

Flags — note these take =, unlike verify:live's space-separated form, and an unrecognized argument exits 2 rather than being ignored: --plan (print the six planned calls and exit, opening nothing), --headed, --profile=<url>, --keywords=<text>/--location=<text>, --feed-count=<n>/--jobs-count=<n>/--invites-count=<n>/--connections-count=<n> (each capped at 50), --direction=received|sent|both, --only=<tool>, --state-dir=<path>, --timeout=<ms>, --json[=<path>], --verbose/-v.

The --json report lands in .linkedin-mcp/ by default and contains real content from your account, so the same privacy note as --json above applies — and the harness refuses to write to validation-report.json, so a run cannot clobber the live validator's report.

Exit codes: 0 = nothing came back measurably empty and no returned value was refused as implausible. 1 = at least one field was empty on a page that did return records — or that had rows on it and returned none of them — or a value came back that cannot be what it claims to be — go look at that selector. 2 = the run never happened (no build, a gate refused the plan, no session, a challenge, Chromium could not launch). An unconfirmed name never changes the exit code: a slug is too weak to fail a run on, and a harness that failed on nicknames would be turned off within a week. Neither does an echoed About or a no selector name — all three are caveats printed beside a verdict, and the two rules they obey are that a correct payload may not fail a run and that a caveat may not be silent.

When a selector misses and you need to know why

npm run inspect:profile -- --profile=https://www.linkedin.com/in/<your-slug>/

The two checks above return verdicts: a key is proven, empty, or missed. Neither tells you which part of a selector broke, and for the profile sections that distinction is the whole question. 'section:has(h2:has-text("Experience")) li' makes three independent claims — there is a <section>, it contains an <h2> reading Experience, and that section contains <li> items — and a miss says only that their conjunction failed. Rewriting the selector on that evidence is guessing, and a guess that happens to work is indistinguishable from one that will break next week.

So this script gathers evidence and prescribes nothing. It opens one profile, waits, and reports:

  • Every profile selector's match count and visible count, per candidate. 2 match(es), 0 visible and 0 matches have opposite fixes — a wait versus a new selector — and only this distinction tells you which you need.

  • Headings found by their text, not by the selector. For each of About, Experience, Education and Skills it prints the heading node, its <section> ancestor (or none — section:has(...) cannot match), its nearest componentkey ancestor, and how many li / role=listitem / [componentkey] children that container holds. Each of the three claims is then falsifiable on its own. When the sections are there and the li items are not, the guidance goes one step further and names the wrapper the items actually use[role="listitem"] if the semantic one is present, [componentkey] only if it is not — so the finding is actionable without a second run. If no wrapper exists at all it says so instead of naming one, because a shape inferred from an absence is a guess, and a guess reported as evidence is how a selector gets replaced blindly.

  • Card-scoped keys are probed inside a card, never page-wide. itemTitle, itemSubtitle, itemDateRange and itemDescription are resolved by the scraper within one experience/education/skill entry (textWithin(item, …)), so grading them against the whole document measures a chain nobody runs. It used to: the last itemTitle fallback is a bare span.t-bold, and on a real profile it matched the messaging overlay and printed a green ✓ for a chain that extracts nothing. The script now finds a real card first and probes inside it; when no card matches it marks those four · with not probed page-wide: that would grade a selector against the wrong DOM rather than inventing a verdict.

  • An inventory of every componentkey on the page, most frequent first, with a sample of its text — the raw material for a replacement selector, sourced from the page rather than from memory.

  • What pickIdentityPair sees: every top-card lockup, its lines, which one won, and the sentence explaining each refusal. This is where you find out whether the name you got came from the member or from the card above them.

  • Which of the tool's two identity passes actually answered, and the two values it returns. The tool does not go from a contradicted lockup straight to the page metadata: identityFromPair in src/tools/scrape.ts consults the independent candidate chain (SELECTORS.profile.name, judged by the same corroboration policy) in between, and only falls back to <title> when that is contradicted too. This report modelled the first and third steps and skipped the second, and on the live self-profile cohort — where every lockup is contradicted and the flat chain answers on its first candidate — it therefore printed "every candidate was contradicted — the tool falls back to Parth Bansal from title and withholds the headline" for a run where the tool returned both fields from selectors. That is not a cosmetic bug in a diagnostic: this report is the instrument a release gate gets read from, so it invented a defect that was not there, and by never printing the pass that answers it would equally have hidden a real one — a flat chain returning a decoy was not shown at all. The branch is now a pure function, resolveIdentityPath in src/validation.ts (11 unit tests, including the regression that the chain must be preferred over the metadata when both could answer), the script drives it behind the tool's own corroborated.pick === null guard, contradictions are tagged with the pass they came from, and the report closes with a what linkedin_scrape_profile returns here block naming the name, the headline and which of the four paths supplied them — a top-card lockup, the independent candidate chain, the page’s own metadata, not a selector, or nothing — the tool throws rather than guess. No selector was changed for this; the thing that was wrong was the description of the walk.

  • Where the name in <title> actually lives in the body. The one question a live run has never been able to answer. When every name candidate returns somebody else's text and the tool falls back to <title>, that proves only that the name is unreachable by every candidate as written, and says nothing about what to write instead — so the script takes the string <title> already gave it and searches the document for the element holding it, reporting the tag, whether it is inside the top card, its anchor and its ancestor chain. It prefers a visible copy and then the card's copy, because a profile prints the member's name in the activity feed and the analytics panel too, and a hidden copy is usually an a11y label. The decisive line is whether the tag is reachable: the leaves SELECTORS.profile.name actually ends at are p, h1 and h2, so a name living in a <span> is unreachable at any scope, and that is a repair to the leaf rather than to the scope — a distinction the probe table cannot express, because a chain that matched the wrong element and a chain that could never match the right one both read there as a miss. h2 is on that list because a live run put a name in one and this script is what established it; the set lives in NAME_LEAF_TAGS, drives both the verdict and the sentence printed beside it, and a test re-derives it from the real candidate list and fails if the two drift — a stale copy here would go on calling a repaired name unreachable, which is the one error this script must not make. If no element carries the name it says nowhere instead of naming one, which is itself the sharpest finding available: no selector can be written, and the metadata fallback is not a workaround but the only path that exists.

  • A second pass after scrolling, and a diff. A key that goes absent → visible is a lazy-render problem; a key absent in both passes is not.

  • The page's own geometry, so a one-step scroll is not mistaken for a walk that gave up. The live self-profile run reported Experience, Education and Skills matching nothing, no such heading anywhere in the DOM, empty profile_top_card_experience_lazy_anchor_… containers — and a scroll pass of exactly one step. Read on its own that is ambiguous in the worst possible way: either LinkedIn does not render those sections for this cohort, or the walk stopped before reaching them, and the two have opposite remedies. It was not the walk giving up — scrollThrough breaks out of its loop as soon as the bottom is in view, which on a short SDUI page happens at step 1 — but the report gave a reader no way to know that, and the numbers that prove it were being measured inside the same page.evaluate and then thrown away. They are now kept and classified by scrollReachVerdict into one of five verdicts: one-viewport (the document never exceeded its viewport and never grew — so nothing was deferred below the fold and a section missing here is missing from the page: do not rewrite the item selectors), grew (the document got taller while the viewport moved, so this page defers something — just not these sections), walked (a multi-viewport page whose bottom the walk actually saw), incomplete (the bottom was never reached, so re-run with --settle 8000 before concluding anything), and unknown (the geometry could not be read, so the scroll pass settles nothing either way). walked is returned only on positive evidence, never as the leftover branch — a walk a raced navigation aborted on step 2 of 8 reads as incomplete, because it has strictly less evidence than one that spent its whole budget. The verdict is printed beside the step count, cited in the guidance block at exactly the one branch it can settle (no heading found at all), and written to --json as reach, and printReach returns the same verdict string it printed so the sentence and the JSON cannot drift apart. No selector was changed for this either; a [componentkey] rewrite of experienceItem / educationItem / skillItem on this evidence would be exactly the blind replacement this project forbids.

  • A "consistent with" paragraph naming the cause each pattern supports — and, when all three claims hold and the tool still returned nothing, saying so, because that points at the tool rather than the selector. It weighs only the headings belonging to the keys that actually missed (headingsFor), and that scoping is a repair, not a detail. A live run on the self-profile cohort had About present and Experience, Education and Skills absent, and because the block counted all four wanted headings it read "one heading is here, it sits in a section, its children are [componentkey]" and printed the items are [componentkey] children — under: About and so the item half wants [componentkey], not li — advice about three sections whose headings were nowhere on the page, derived entirely from the one that was. Worse, that single unrelated heading suppressed the true branch, the sections are not in this page at all, and with it the geometry note that is the only thing able to separate absent from deferred. Two other instruments read that same page correctly (verify:read-tools warned that neither Experience, Education nor Skills was present; the scroll pass changed nothing), so the diagnostic was the one thing lying. A heading now answers only for its own key: About present says nothing whatsoever about Experience. No selector was changed for this, and a [componentkey] rewrite on the strength of the old output would have been precisely the blind replacement this project forbids — which is what makes a mis-attributing diagnostic more dangerous than a silent one.

Its safety posture is the strictest of the three scripts:

  • One navigation. No clicks, no typing, no form submission, no menus opened. It cannot reach a write control because it never presses anything.

  • It refuses any URL that is not a member profile (isMemberProfileUrl in src/validation.ts, unit-tested — deliberately narrower than the allowlist verify:live uses, which admits /feed/ and /jobs/search). A sub-path such as /details/experience/ is normalized to the profile root first, exactly as linkedin_scrape_profile does it, so you are always looking at the page the tool looks at.

  • A challenge or a missing session is a hard stop, printed as a manual step for you to complete in your own browser. Nothing is solved, bypassed or retried.

  • The session file is read, never rewritten.

  • It changes nothing on disk except an explicit --json report.

The one place it is deliberately wider than verify:live: that script aborts every non-GET request, which is why Experience, Education and Skills can never render under it. This script aborts only requests carrying a mutation marker, so the RSC POST that renders those sections completes — which is the entire reason it can see them. It is strictly narrower than verify:read-tools, which runs the tools normally. verify:live's kill switch is untouched.

Flags: --profile=<url>, --settle=<ms> (0–30000, default 4000), --no-scroll, --headless, --json <path>, --help. An unrecognized flag exits 2. Exit codes: 0 = the inspection ran, 2 = it did not (no build, a bad URL, no session, a challenge, Chromium could not launch).

Tests

The suite is run by Vitest (tests/**/*.test.ts) and currently covers the rate limiter (src/rateLimiter.ts) including its cross-process counter lock, the reserve-and-refund quota envelope (src/quota.ts), the config loader (src/config.ts), fixture routing (src/fixtures.ts), the live-validation logic (src/validation.ts), the profile identity policy (isPromotionalText / isUiLabelText / isStatusBadgeText / isWorkPreferenceText / identityTextRejection / pickIdentity / pickIdentityPair in src/selectors.ts, plus a shape guard that keeps the page-wide name and headline candidates in last place, and ownProfileLockups, whose generated selectors are checked by extracting the href operator back out of each candidate and applying it to a list of real hrefs — the claim worth testing is that no sub-page URL can match, which is a property of $= rather than of the string's shape, and Vitest runs in Node with no DOM to ask), the confirmation ledger (src/confirm.ts), the Easy Apply completeness gate (applyStepProblems / matchAnswer in src/tools/jobs.ts), the Easy Apply filter enforcement (enforceEasyApplyFilter in src/tools/jobs.ts), read-only mode, the live validator's identity gate (identityDecoy in scripts/validate-live.mjs, driven with the real policy and a stubbed page reader), the identity corroboration policy (identityCorroboration and auditIdentityAgainstUrl in src/validation.ts — what <title>, og:title, a portrait alt and a profile slug may and may not conclude about a returned name, including the bound that stops a card title which merely starts with the member's name from being read as agreement, and nameFromEvidence, which decides what the page may be asked to supply once every selector has been vetoed), the live validator's reader for those four sources and the two printers that report their verdict (readIdentityEvidence, identityEvidenceLines, identityMismatchLines), the identity path the tool actually walks (resolveIdentityPath / withholdsHeadline in src/validation.ts — which of the two selector passes answered, and which single one of the four paths withholds a headline on purpose), the scroll geometry that decides whether a missing section is absent or merely deferred (scrollReachVerdict / printReach in scripts/inspect-profile-dom.mjs), the warning that names which half of a card list failed when every row on the page was dropped for want of a readable name (cardListChain / unreadableCardsWarning in src/validation.ts — it may blame neither the card selector, which matched, nor call the list empty, which it was not), the heading scoping that stops a section which is on the page from answering for one that is not (isWanted / headingsFor in scripts/inspect-profile-dom.mjs), the invariant that a container-scoped candidate may not name its own container (splitSelectorList / selectorSteps / simpleSelectors / stepDescribes / scopedSelfReferences in src/validation.ts — including a table of the eight real strings that were broken this way, each replayed against the live container candidates so a fixed defect still has a regression test, and a sweep asserting no false positive anywhere in selectors.ts), the accounting that separates an emptied list from an empty account (isEmptiedCollection / recordCountLine and the row count threaded through extractFieldObservationsevaluateToolEvidencesummarizeReadToolEvidence in src/validation.ts — both directions pinned: ten rows returning nothing must fail the run, and a genuinely empty invitations list must not), the carve-out that stops that same row count from convicting the card selector it was taken with (containerFieldName and the unsettled verdict in src/validation.ts — a container is proven by rows on the page and left unsettled by their absence, the exemption applies to no scalar tool and lapses when a tool publishes no count, and the precedence proven > empty > unsettled > inconclusive holds across tools), the DOM inspector's report path (scripts/inspect-profile-dom.mjs), owner-only permissions for session directories and artifacts (ensurePrivateDirectory / ensurePrivateFile in src/browser.ts), and the account-wide write mutex (src/writeLock.ts) — 1255 cases in sixteen files. All of them are hermetic by construction: every case runs against a fresh mkdtemp directory, the environment is passed in explicitly rather than read from process.env, and the rate limiter's clock, sleep, and randomness are injected through RateLimiterDeps. They make no network calls and launch no browser. Two of the files import a script that does drive a browser — scripts/validate-live.mjs and scripts/inspect-profile-dom.mjs — and neither starts one, because each script runs its main() only when it is the process entry point and exports the page-independent half for exactly this purpose. Those two are the parts no fixture can reach: they only ever run against real LinkedIn, so the alternative to testing them this way is not testing them at all. The lock tests are the one place that spends real wall-clock milliseconds — a contended lock has to be waited on with a real timer, so lockTimeoutMs is injected short instead of the clock being frozen.

Anything that needs a browser lives in the dry-run sweep above, not in the unit suite — which is why npm test finishes in under a second and needs nothing installed beyond node_modules.

Conventions worth knowing before you edit

  • Nothing may write to stdout. stdout is the JSON-RPC channel; one stray byte desynchronizes framing and the client drops the connection. All diagnostics go to stderr through the Logger.

  • The project is ESM with moduleResolution: "NodeNext", so relative imports must end in .js even in TypeScript source.

  • src/types.ts and src/errors.ts are the shared contract. src/selectors.ts is strings and pure functions only.

  • A card-scoped candidate must be a strict descendant of its card. A SELECTOR_MANIFEST entry carrying scopeOf is resolved inside an already-matched container — textWithin(card, …) in src/tools/connect.ts, probeChainWithinSiblings in scripts/validate-live.mjs — and Playwright's locator.locator(sel) has element.querySelectorAll(sel) semantics. So a candidate that leads with a compound describing its own container asks for a copy of the container nested inside itself and can never match, on any DOM, at any casing. SELECTORS.connections.connectionName was written [componentkey^="ConnectionCard_"] a p with scopeOf: 'connections.connectionCard', and with its two RSC candidates dead by scope and its four legacy classes dead by age it had zero viable candidates: a live run matched all ten connection cards and then dropped all ten for want of a name, and the harness filed it under nothing to observe. scopedSelfReferences() in src/validation.ts now finds that shape statically — comparing each candidate's first step against the subject (last step) of every container candidate — and both live harnesses refuse to open a browser while one exists, exit 2, naming the key and the string. 'opener' entries (gate: 'behind-menu', where the selector legitimately is the container's own trigger) are exempt.


Troubleshooting

"I started it and nothing happens" — that is success

Run npm start by hand and you will see one line on stderr and then apparent silence:

{"ts":"...","level":"info","msg":"linkedin-mcp ready on stdio","version":"0.1.0","tools":11,"dryRun":false,"readOnly":false,"headless":false}

That is a healthy server, not a hang. An MCP stdio server is not a daemon with a port and not a CLI that prints a result and exits. It reads JSON-RPC requests from stdin and writes responses to stdout, so after announcing itself it blocks waiting for a client to say something. With no client attached, there is nothing to say, and a correct server stays quiet rather than printing anything to stdout — one stray byte there would desynchronize the protocol framing.

So: npm start is not how you use this. Register the server with an MCP client (below) and let the client spawn it. If you want to prove it responds while it is sitting there, paste a request into its stdin and press Return — a tools/list reply should come straight back:

printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | node dist/server.js --dry-run

Press Ctrl-C to stop a hand-started server. It shuts down on SIGINT/SIGTERM and logs shutting down.

Two related symptoms:

  • Your client shows 0 tools, or "server failed to start". Almost always the path in args is wrong or dist/ was never built. Use an absolute path to dist/server.js, run npm run build, and check your client's own MCP log — the server's stderr ends up there, and config_invalid or a Node module-resolution error will be sitting in it.

  • You see the ready line but every tool fails. Check what that line says. dryRun:true means fixtures only, and nothing you do will reach LinkedIn; not_authenticated on every call means there is no saved session yet, so run linkedin_login.

Error codes

Failures come back as MCP tool errors carrying a stable code. The ones you are most likely to see:

verification_required — LinkedIn has put up a CAPTCHA, a checkpoint, or an authwall. The server stops here on purpose and will never try to solve it. Open linkedin.com in your normal browser, clear the challenge as yourself, then run linkedin_login again. If this keeps happening, treat it as a signal to lower your daily caps.

session_expired — the saved session no longer authenticates, or LinkedIn redirected the feed to its sign-in page. Run linkedin_login and sign in by hand again. linkedin_session_status will show you the specific reason.

selector_not_found — the server could not find an element it needed. This almost always means LinkedIn changed its markup, not that you did anything wrong. Every DOM selector in the project lives in src/selectors.ts, which is the single maintenance point: find the relevant candidate list, add a new selector to the front of it, and rebuild. Candidates are ordered lists, so an added selector does not break the old ones. Running with --log-level debug tells you which lookup failed.

rate_limited — you have hit a daily cap. The error and the quota block tell you which one and when it resets (next local midnight). Wait it out, or raise that cap in config.json — but raising a cap is exactly the behaviour that gets accounts restricted, so raise it deliberately.

external_application — the job posting hands applicants off to an external applicant-tracking system rather than LinkedIn's Easy Apply form. The server will not fill in third-party sites. Open the job in a browser and apply there.

not_connected — you tried to message someone who is not a 1st-degree connection. Send a connection request first, wait for it to be accepted, then message. The server will not work around this with InMail.

read_only_mode — the server was started with --read-only (or LINKEDIN_MCP_READ_ONLY=1, or "readOnly": true) and you called one of the four write tools. Nothing was attempted; the refusal happens before the tool runs. Restart without the flag to allow writes. See Read-only mode.

Other codes you may encounter: not_authenticated (no session on disk yet — run linkedin_login), not_easy_apply, invalid_input, confirmation_required (confirm: true arrived with no previewToken — preview first), confirmation_invalid (the token was never issued by this server, has already been used, or the server restarted — preview again), confirmation_expired (the preview is older than 10 minutes — preview again), confirmation_mismatch (arguments changed between preview and confirm — preview again), navigation_failed, browser_error, dry_run_unsupported, config_invalid (a malformed config.json, reported before the server starts), and file_not_found.


What this does NOT do

  • No InMail. Messaging is 1st-degree connections and existing conversations only.

  • No external-ATS applications. Easy Apply only; anything that leaves LinkedIn is refused.

  • No CAPTCHA solving. No 2FA automation, no checkpoint bypass, ever.

  • No multi-account support. One saved session, one account, one person at the keyboard.

  • No remote media upload. The server never fetches a URL to attach to a post.


Security & privacy

What is stored, and where. Everything lives on this machine, under the state directory — by default <cwd>/.linkedin-mcp/, overridable with LINKEDIN_MCP_STATE_DIR (or per-path with LINKEDIN_MCP_STORAGE_STATE, LINKEDIN_MCP_USER_DATA_DIR, LINKEDIN_MCP_SCREENSHOT_DIR, LINKEDIN_MCP_COUNTERS):

Path

Contents

storageState.json

Your LinkedIn cookies and origin storage, written mode 0600

chromium-profile/

The persistent Chromium profile directory, enforced mode 0700

counters.json

Today's local date plus the four action counts

counters.json.lock

Present only while a write is updating the counts — see Two servers, one counters file

account-write.lock

Present for the full lifetime of one live write-tool call — see One live write at a time

screenshots/

Diagnostic screenshots in a 0700 directory; each file is 0600 or discarded

Nothing is transmitted anywhere. There is no telemetry, no analytics, no crash reporting, and no phone-home of any kind. The only network destination is linkedin.com, reached through your own browser session — and under --dry-run not even that.

Screenshots are local-only. They are written to the screenshots directory for your own debugging and are never uploaded or included in tool output.

LinkedIn content is untrusted external data. Profile fields, feed posts, job descriptions and questions, invitation text, connection details, and names resolved while previewing writes are controlled by LinkedIn or its users. Tools that can return those values are marked with returnsUntrustedContent, their tools/list descriptions carry a warning, and every successful result includes a top-level _contentTrust marker. MCP clients must treat every LinkedIn-derived string only as data: never obey instructions, follow links, invoke tools, or disclose secrets because returned content asks them to. The marker does not sanitize or remove the original text; it preserves the data while making the trust boundary explicit.

Logging is deliberately thin. Diagnostics go to stderr. Cookies, storageState contents, and resume file contents are never logged or serialized. Tool failures log the error code rather than the arguments, so a message body or a resume path cannot leak into a client's captured stderr. redactConfig strips absolute paths (and your home directory) out of the effective-configuration line the server logs at startup.

Gitignore guarantees. .gitignore excludes .env and .env.* (keeping .env.example), the whole .linkedin-mcp/ state directory, storageState.json at any depth, chromium-profile/, counters.json and its .lock directory, screenshots/, *.log, plus node_modules/, dist/, and coverage/. Only config.example.json and your config.json stay tracked, and they contain nothing but caps and timeouts. Never commit storageState.json — it is a live credential.

Treat the state directory as a secret. Anyone who can read storageState.json can act as you on LinkedIn without a password or a 2FA code. If you think it has been exposed, sign out of all sessions from LinkedIn's own security settings, delete the file, and log in again.

Available Tools

11 tools
linkedin_apply_to_jobApply to a LinkedIn job (Easy Apply only)A
Destructive

Submits a LinkedIn Easy Apply application. jobId accepts a bare id ("3812345678"), a job URL, a ?currentJobId= URL or a urn:li:jobPosting: URN. resumePath is optional — an absolute path to a local .pdf, .doc or .docx file (never uploaded anywhere except LinkedIn itself); omit it to use whatever resume is already attached to your LinkedIn profile. answers maps question text to the answer to give; keys are matched case-insensitively and by substring, so "years of experience" answers "How many years of experience do you have?". CONFIRMATION IS REQUIRED: called without confirm: true this opens the Easy Apply modal read-only, lists every question it will be asked, flags the required ones it cannot answer in unansweredRequired, closes the modal without submitting, and returns a previewToken. Re-issue the identical call with confirm: true to actually submit. Postings that apply through an employer's own site are refused with external_application and their URL handed back for you to use by hand; postings with no Easy Apply button are refused with not_easy_apply. If a required question still cannot be answered at submit time the application is abandoned with invalid_input rather than sent incomplete — this tool never submits a partial application. Counts against the daily jobApplications cap.

ParametersJSON Schema
NameRequiredDescriptionDefault
jobIdYes
answersNo
confirmNo
resumePathNo
previewTokenNo

TDQS

A4.8/5.0
Behavior4/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Annotations indicate readOnlyHint=false, destructiveHint=true, idempotentHint=false, openWorldHint=true. The description goes beyond annotations by disclosing the two-phase confirmation requirement, the refusal reasons, and that it never submits partial applications. It also clarifies that resumePath is never uploaded anywhere except LinkedIn. While it doesn't detail every failure mode, it covers essential behavioral traits beyond the annotations.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is comprehensive yet well-structured. It front-loads the core action, then details each parameter, then explains the confirmation flow and failure modes. Every sentence adds value, and it's written in a logical order. It's long but justified given the complexity.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

Given the tool's complexity (5 params, nested objects, validation logic) and lack of output schema, the description provides complete guidance: parameter formats, confirmation flow, refusal cases, failure handling, and quota impact. No critical information missing for an agent to call it correctly.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters5/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema coverage is 0%, so the description must fully explain each parameter. It does: jobId formats, resumePath optionality and accepted file types, answers matching semantics (case-insensitive, substring), confirm flag behavior, and previewToken usage. This adds significant meaning beyond the bare schema properties.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly states the tool's purpose: submitting a LinkedIn Easy Apply application. It specifies the verb (submits), resource (LinkedIn Easy Apply application), and key details like jobId formats and confirmation requirement. It distinguishes itself from siblings like linkedin_search_jobs by focusing on the application action.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines5/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

Explicitly explains when to use: for Easy Apply postings, and when NOT to use: external applications and non-Easy Apply postings are refused. It also clarifies the confirm flag flow (preview vs submit). Mentions alternatives implicitly (handling external URL manually). Provides clear context for when this tool is appropriate versus others.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

linkedin_create_postCreate a LinkedIn postA
Destructive

Publishes a text post to the signed-in member’s LinkedIn feed. This is a two-step tool: called without confirm: true it only returns a preview (character count, line count, audience, and a previewToken) and touches nothing on LinkedIn; call it again with the same arguments plus confirm: true to actually publish. Counts against the daily posts cap. visibility defaults to "public" (Anyone); "connections" restricts the post to 1st-degree connections. LIMITATION: mediaUrl is accepted for validation but images and video are NOT supported — this server never performs its own network fetches, so it cannot download remote media, and confirming a call that sets mediaUrl is refused with invalid_input rather than silently publishing text only. Post without media, or attach the image by hand in LinkedIn afterwards. Posts over roughly 1300 characters are collapsed by LinkedIn behind "…see more" (a warning, not an error). Under --dry-run the composer is driven against a local HTML fixture and the final "Post" click is skipped.

ParametersJSON Schema
NameRequiredDescriptionDefault
textYes
confirmNo
mediaUrlNo
visibilityNo
previewTokenNo

TDQS

A4.7/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Annotations already mark destructive=true, but the description adds substantial context: the two-step preview/publish mechanism, daily posts cap, visibility default, media refusal with invalid_input, 1300-character collapse warning, and dry-run behavior. This goes well beyond what annotations provide and contains no contradictions.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

While long, every sentence adds operational value: purpose first, then invocation pattern, then limitations and edge cases. There is no filler or redundant phrasing; the structure logically leads the agent through what to expect.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness4/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

Covers the preview response fields, error handling for media, warning threshold, and dry-run behavior. The only notable gap is that it doesn't describe what the final publish call returns (e.g., post ID or confirmation response), which would be useful since there is no output schema. Still, this is minor relative to the volume of useful context.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters5/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema has zero descriptions, so the description carries the full burden. It explains text (content), confirm (publish gate), mediaUrl (accepted but unsupported, refused on confirm), visibility (default 'public', meaning of 'connections'), and previewToken (returned from preview). All parameters are meaningfully clarified.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description opens with a specific verb and resource: 'Publishes a text post to the signed-in member’s LinkedIn feed.' This clearly distinguishes it from siblings like send_message or scrape_profile and leaves no ambiguity about what the tool does.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

It explains the two-step invocation pattern (preview without confirm, publish with confirm) and the media limitation as a clear 'not supported' case with manual workaround. It doesn't explicitly mention sibling alternatives, but the usage context and when-to-publish guidance are strong.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

linkedin_list_connectionsList LinkedIn connectionsA
Read-onlyIdempotent

Lists the signed-in member’s 1st-degree connections, most recently added first (LinkedIn’s default sort). count caps how many are returned (default 50, max 200) and the list is scroll-paginated until that many are loaded, the list stops growing, or an internal scroll cap is reached. query is an optional case-insensitive substring filter applied LOCALLY to the name and headline of the connections that were loaded — it is not LinkedIn’s own search, so a match that sits far down a very long list may not be reached. Read-only: takes no confirm and changes nothing. Each entry carries name, headline, canonical profile URL, and a "connected at" timestamp when LinkedIn exposes one. total is the account-wide count from the page header when it can be read (totalSource: "page-header"), otherwise the number of cards that were loaded.

ParametersJSON Schema
NameRequiredDescriptionDefault
countNo
queryNo

TDQS

A4.7/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Annotations already declare readOnlyHint, openWorldHint, idempotentHint, and destructiveHint, so the bar is lower. The description adds significant behavioral detail beyond that: pagination mechanics (scroll-paginated until count met, stops growing, internal cap), how the query filter is applied locally, the total count source behavior, and the per-entry fields including 'connected at' timestamp. This goes well beyond the structured hints.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness4/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is dense but efficient—about 5 sentences covering all important aspects. It is front-loaded with the core purpose and sorted order, then details parameters and output. Could be slightly more concise (e.g., merging some clauses) but every sentence adds value without fluff.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

For a tool with only 2 optional parameters and no output schema, the description covers all necessary context: purpose, sorting, pagination, parameter semantics, output fields, total count source, and read-only nature. An agent can confidently call it and interpret results without needing additional external knowledge.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters5/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema coverage is 0%, so the description must fully explain both parameters. It does: count is given a default (50), maximum (200), and describes pagination; query is defined as a case-insensitive substring filter applied locally to name and headline. This fully compensates for the lack of schema descriptions.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly states the tool lists the signed-in member's 1st-degree connections with a specific sort order (most recently added first). This distinguishes it from siblings like linkedin_send_connection_request (mutating) and linkedin_scrape_profile (single profile), leaving no ambiguity about what it does.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description explains when to use it (listing connections) and clarifies that the query filter is local, not LinkedIn's search, which warns against misuse for large lists. However, it does not explicitly point to alternative tools for actions like sending requests or scraping profiles, though the sibling names make that implicit. Slight gap in explicit exclusions.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

linkedin_list_pending_invitesList pending LinkedIn invitationsA
Read-onlyIdempotent

Lists invitations that are still awaiting a decision. direction: "received" (the default) returns invitations other people sent you; direction: "sent" returns invitations you sent that have not been accepted yet. count caps how many are returned (default 25, max 100). Read-only: this never accepts, ignores or withdraws anything, and takes no confirm. Each entry carries name, headline, canonical profile URL, and a timestamp (the datetime attribute when LinkedIn provides one, otherwise the relative text such as "3 days ago"). An empty list is a normal result, not an error. total is how many invitation cards were found on the page, which can exceed returned when count is smaller.

ParametersJSON Schema
NameRequiredDescriptionDefault
countNo
directionNo

TDQS

A4.4/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

The description explicitly states the tool never accepts, ignores, or withdraws anything, and takes no `confirm`, adding real behavioral context beyond the readOnlyHint annotation. It also discloses edge cases such as empty lists being normal and the difference between `total` and `returned`.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is dense but every sentence earns its place: purpose, parameter semantics, read-only guarantee, output format, and edge-case handling. It is front-loaded with the core action and then efficiently covers nuances without redundancy.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

With no output schema, the description fully explains what entries contain, how timestamps are represented, that an empty list is valid, and how `total` relates to `returned`. Combined with parameter coverage and side-effect disclosure, nothing critical is missing for calling this tool correctly.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters5/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 0%, so the description carries full responsibility for explaining parameters. It thoroughly documents the `direction` enum values with defaults, `count` default and maximum, and even explains result-field meanings that relate to the parameters, fully compensating for the schema gap.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose4/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly states the tool lists 'invitations that are still awaiting a decision' and specifies the resource precisely. It also distinguishes sent vs received directions, but does not explicitly differentiate from the similar sibling tool `linkedin_list_connections`, so it falls just short of a 5.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines3/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description gives strong operational guidance: direction defaults, count limits, and read-only behavior. However, it never mentions when to prefer this tool over alternatives like `linkedin_list_connections` or `linkedin_send_connection_request`, leaving selection context implied rather than explicit.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

linkedin_loginLog in to LinkedIn (interactive)A
Idempotent

Opens a visible Chromium window on LinkedIn’s login page and waits for the human to sign in themselves, including any 2FA or CAPTCHA step. This tool never types credentials and never reads the password field. When LinkedIn shows a signed-in feed, the browser session is saved to local disk (permissions 0600) and reused by every other tool. Takes no arguments. Not available under --dry-run.

ParametersJSON Schema
NameRequiredDescriptionDefault

No parameters

TDQS

A4.7/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Annotations already indicate readOnlyHint=false and idempotentHint=true, but the description adds valuable context: it opens a visible browser, waits for human input, never types credentials, saves the session to local disk with permissions 0600, and is interactive. This goes beyond annotations and provides security-relevant details that affect agent behavior.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is front-loaded with the key action (opens browser, waits for human) and then covers important caveats (never types, session saved, not under dry-run) in a compact set of sentences. Every sentence contributes value, and the structure is scannable.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

Given the tool's interactive nature and reliance on human input, the description covers the essential flow: what it does, how it completes (when feed appears, session saved), and constraints (no credential handling, file permissions). It doesn't specify failure handling, but for a login tool this is acceptable and the description is complete for an agent to invoke it correctly.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters4/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

The tool takes no arguments, and the schema confirms an empty properties object. The description redundantly states 'Takes no arguments.' Since there are no parameters to document, the baseline of 4 applies, and the description adds no misleading information.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly states the tool's purpose: opens a visible Chromium window on LinkedIn's login page and waits for the human to sign in. It specifies the verb (opens, waits), resource (LinkedIn login page), and explicitly distinguishes itself from siblings by emphasizing it requires human interaction and never types credentials.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description communicates when to use it: when a human must authenticate interactively, including 2FA/CAPTCHA. It also notes the session is saved and reused by other tools, implying usage before authenticated operations, and states it's unavailable under --dry-run. However, it doesn't explicitly name alternatives like linkedin_session_status for checking login status, or mention when not to use it.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

linkedin_scrape_feedScrape the LinkedIn feedA
Read-onlyIdempotent

Reads the signed-in member's home feed and returns the posts as structured data: author name and headline, post text, permalink, reaction and comment counts, and a timestamp. count is how many posts to return (default 10, max 50). The feed is lazy-loaded, so reaching a high count means scrolling, and this tool pauses a randomized human-like interval between scrolls — a large count can therefore take a minute or more. Read-only: takes no confirm, consumes no daily quota, and never likes, comments or reposts; the only clicks are "…see more" expanders so post text is captured at full length. Posts are de-duplicated by permalink (LinkedIn recycles cards while scrolling) and sponsored or suggestion cards with no readable author are skipped. Fewer posts than requested is a normal result, not an error.

ParametersJSON Schema
NameRequiredDescriptionDefault
countNo

TDQS

A4.8/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

The description substantially exceeds the annotations' coverage. It discloses the read-only nature (never likes/comments/reposts, no confirm, no quota), explains the lazy-loading with human-like pauses and the time implication for large counts, de-duplication by permalink, skipping of sponsored cards, and that fewer-than-requested results is normal. This rich behavioral detail helps the agent anticipate side effects and performance without contradicting the annotations.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Though longer than average, every sentence earns its place. The opening sentence states the core purpose, then parameter semantics, behavioral safety, de-dup/skipping logic, and the normal-result caveat are each addressed succinctly. The structure is front-loaded and contains no filler or repetition.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

For a tool with one parameter and no output schema, the description covers everything an agent needs: what it returns, how it behaves, potential pitfalls (time, fewer results), and safety guarantees. There are no meaningful gaps that would prevent correct invocation.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters5/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 0%, so the description must fully compensate. It does: it explains that count is how many posts to return, defaults to 10, has a max of 50, and that a larger count involves scrolling and can take a minute or more. This goes far beyond the schema's bare integer min/max, giving the agent the context needed to choose an appropriate value.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description states a specific verb ('reads') and resource ('signed-in member's home feed'), and enumerates the exact output fields (author name/headline, post text, permalink, reaction/comment counts, timestamp). This clearly distinguishes it from sibling tools like linkedin_scrape_profile, which targets profiles, and linkedin_create_post, which writes.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description unambiguously communicates that this tool is for reading the home feed, which sets it apart from siblings (profiles, jobs, messaging). However, it does not explicitly name alternative tools or state conditions when this tool should be avoided (e.g., 'if you need profile data, use linkedin_scrape_profile'), leaving that inference to the agent.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

linkedin_scrape_profileScrape a LinkedIn profileA
Read-onlyIdempotent

Reads a LinkedIn member profile and returns it as structured data: name, headline, about, location, connection degree, experience, education and skills. profileUrl accepts a full URL ("https://www.linkedin.com/in/john-doe"), a path ("/in/john-doe") or a bare slug ("john-doe"); omit it entirely to read the signed-in member's own profile. Read-only: takes no confirm, consumes no daily quota, and the only thing it clicks is the "…see more" expander, which reveals text already on the page. Profile sections LinkedIn hides from the viewer come back as null or [] rather than as an error — only a profile with no readable name fails (selector_not_found), because that means the page is not a profile. connectionDegree is 1, 2 or 3, and null when no badge is shown (which includes your own profile). skills is capped at 50 entries, experience at 25, education at 15.

ParametersJSON Schema
NameRequiredDescriptionDefault
profileUrlNo

TDQS

A4.8/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Even though the annotations already declare readOnlyHint and idempotentHint, the description adds substantial behavioral detail: it consumes no daily quota, only clicks the '…see more' expander, returns null/[] for hidden sections, and specifies the sole error condition (selector_not_found). It also clarifies connectionDegree semantics and result caps, going well beyond the annotations.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is long but densely informative, with the core purpose and return fields front-loaded followed by parameter formats, behavioral notes, and edge cases. Every sentence adds operational value; no filler or tautology is present.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

With one optional parameter, strong annotations, no output schema, and no nested objects, this description covers all necessary invocation details: input formats, return fields, error behavior, and limits. An agent can correctly select and call this tool without needing additional documentation.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters5/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

The schema only defines profileUrl as a string with no description, giving 0% coverage. The description fully compensates by explaining all accepted input formats, the default when omitted, and what the tool reads from that URL. This gives an agent everything needed to pass the parameter correctly.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description states a specific verb and resource: 'Reads a LinkedIn member profile and returns it as structured data,' and enumerates the exact fields returned. It is clearly distinguishable from sibling tools like linkedin_scrape_feed and linkedin_search_jobs, which target different data sources.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description gives concrete usage context: accepted profileUrl formats (full URL, path, bare slug) and the behavior when omitted (reads the signed-in member's own profile). It does not explicitly name alternatives or when not to use this tool, but the profile-scoped purpose and sibling names make the choice reasonably clear.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

linkedin_search_jobsSearch LinkedIn jobsA
Read-onlyIdempotent

Searches LinkedIn job postings and returns them as structured data: job id, title, company, location, whether the posting supports Easy Apply, and a URL. keywords is required; location is a free-text place name ("London", "Remote", "New York, NY"). Four optional facets narrow the search — easyApplyOnly, datePosted ("past24h" | "pastWeek" | "pastMonth"), experienceLevel ("internship" | "entry" | "associate" | "midSenior" | "director" | "executive") and remote — each accepted either at the top level or nested inside a filters object; filters wins if you somehow pass both. count is how many postings to return (default 25, max 100); results are lazy-loaded, so a high count means scrolling with a randomized human-like pause between scrolls and can take a while. Read-only: takes no confirm and consumes no daily quota. Cards LinkedIn renders without a usable job id are skipped and counted in skipped. Pass a returned jobId to linkedin_apply_to_job, which handles Easy Apply postings only — so filter on easyApply before trying to apply.

ParametersJSON Schema
NameRequiredDescriptionDefault
countNo
remoteNo
filtersNo
keywordsYes
locationNo
datePostedNo
easyApplyOnlyNo
experienceLevelNo

TDQS

A4.8/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Annotations already mark read-only and idempotent, but description adds substantial behavior: lazy-loading with randomized scroll pauses, scoring and skipping of cards without job IDs (counted in `skipped`), no confirm or daily quota, and the filter precedence rule (filters wins if both present). This exceeds annotation coverage and is genuinely useful.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Although lengthy, every sentence adds value: purpose, parameters, behavior, and downstream workflow. Front-loads the core action and returns, then details. No filler; structure is logical.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

For a complex tool with 8 params, nested objects, and no output schema, the description covers return fields, skipped count, lazy-loading performance, and the apply workflow. It also clarifies default/max count and filter precedence. No critical missing information for an agent to call it correctly.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters5/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 0%, yet the description compensates by explaining each major parameter: required keywords, free-text location examples, all four facets with exact enum values (datePosted and experienceLevel), count default/max, and the nested-versus-top-level filter hierarchy. This is more than the schema provides.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

Clear verb+resource: 'Searches LinkedIn job postings' and explicitly lists returned fields (job id, title, company, location, Easy Apply support, URL). Distinguishes from siblings because no other sibling handles job search; it's the obvious entry point for discovery.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

Provides clear context: explains how to use filters and count, and explicitly routes results to linkedin_apply_to_job for Easy Apply postings. Does not explicitly state exclusions (e.g., when not to use) but the workflow guidance is strong. Lacks a direct 'use instead of X' but that's not needed given sibling separation.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

linkedin_send_connection_requestSend a LinkedIn connection requestA
Destructive

Sends one connection invitation to a member, optionally with a note. Two-call handshake: without confirm: true this only reads the profile (name, headline, connection degree, and whether a Connect control exists at all) and returns a preview plus a previewToken — nothing is clicked and no invitation is sent. Re-issue the same call with confirm: true to send. profileUrl accepts a slug ("john-doe") or any LinkedIn profile URL. note is capped at 300 characters; free accounts are limited to 200 and LinkedIn silently truncates past its own limit, so notes over 200 characters are flagged. The request is REFUSED (with invalid_input, before any quota is spent) when the member is already a 1st-degree connection, when an invitation is already pending, or when LinkedIn offers no Connect control for that profile; the preview says so up front in that case. Counts against the daily connectionRequests cap. Under --dry-run the invitation dialog is driven against a local HTML fixture and the final Send click is skipped.

ParametersJSON Schema
NameRequiredDescriptionDefault
noteNo
confirmNo
profileUrlYes
previewTokenNo

TDQS

A5/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

The description goes far beyond the annotations (readOnlyHint=false, destructiveHint=true) by detailing concrete side effects: no click or send without confirm, quota consumption, note truncation limits per account type, and refusal before quota is spent. This level of transparency is exemplary.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is dense but every sentence adds critical information. It is front-loaded with the core purpose and handshake, then layers on parameter details, refusal conditions, and quotas. No redundancy or filler.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

Given the tool's complexity (two-call handshake, refusals, dry-run, quotas, note limits), the description covers every operational aspect. Without an output schema, it explains what the preview returns and what confirm does. An agent can safely and correctly invoke this tool without further context.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters5/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema coverage is 0% (no descriptions in the schema), so the description carries full burden. It explains profileUrl accepts slug or URL, note has 300-char cap (200 for free accounts with silent truncation), confirm triggers the actual send, and previewToken is part of the handshake. All four parameters are clarified.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly states the tool sends a connection invitation to a member, optionally with a note, and explicitly describes the two-call handshake. It distinguishes itself from siblings like send_message or create_post by focusing on the connection request action and its preview/confirm flow.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines5/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

Provides explicit usage instructions: the two-call pattern (preview then confirm), when requests are refused (already 1st-degree, pending invite, no Connect control), and the dry-run behavior. It clarifies when not to call with confirm:true immediately, covering usage conditions thoroughly.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

linkedin_send_messageSend a LinkedIn direct messageA
Destructive

Sends one direct message, either to a 1st-degree connection (pass a profile URL or slug) or into a conversation that already exists (pass the id from its /messaging/thread// URL). Two-call handshake: without confirm: true this only reads the profile or thread and returns a preview plus a previewToken, and nothing is typed or sent. Messaging is limited to 1st-degree connections — a profile at 2nd/3rd degree (or one whose degree cannot be read) is refused with not_connected, and InMail is never attempted. Counts against the daily messages cap. Under --dry-run everything runs against local fixtures and the final Send click is skipped.

ParametersJSON Schema
NameRequiredDescriptionDefault
textYes
confirmNo
previewTokenNo
profileUrlOrConversationIdYes

TDQS

A4.7/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Beyond annotations (readOnlyHint false, destructiveHint true), the description discloses the two-call handshake, that confirm:true is required to actually send, that it counts against a daily messages cap, the dry-run behavior, and that InMail is never attempted. It also mentions the refusal condition. This is rich, actionable behavioral context that adds real value over the annotations.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is dense yet efficient, front-loading the core action and then packing critical details (handshake, restrictions, dry-run) without fluff. Every sentence adds necessary information and the structure guides the reader through the flow logically.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

Given the tool's complexity (two-call handshake, degree restrictions, dry-run mode), the description covers all essential aspects an agent needs to call it correctly: return of previewToken, refusal with not_connected, daily cap effect, and dry-run behavior. Without an output schema, it adequately explains the expected response shape.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters4/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

With 0% schema description coverage, the description carries the burden of explaining parameters. It explains profileUrlOrConversationId (URL/slug or thread ID), confirm (triggers the send in the second call), and previewToken (returned from the first call). It does not explicitly describe the text parameter, but its meaning is obvious as the message body. This nearly compensates for the lack of schema descriptions.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly states the tool's purpose: 'Sends one direct message' and specifies the two distinct target types (1st-degree connection via profile URL/slug, or an existing conversation via thread ID). This unambiguously distinguishes it from siblings like linkedin_create_post or linkedin_send_connection_request.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

Provides explicit usage conditions: only 1st-degree connections, refusal with not_connected, and the two-call handshake required for sending. It explains when the tool will refuse and how confirmation works, though it does not explicitly name an alternative tool for other messaging scenarios. Still, the context is strong enough for an agent to decide when to use it.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

linkedin_session_statusCheck the saved LinkedIn sessionA
Read-onlyIdempotent

Reports whether the locally saved LinkedIn session still works, when it was saved, and when it was last verified. Loads the feed once to check; no cookie or session content is ever returned. Under --dry-run this always reports valid. Takes no arguments.

ParametersJSON Schema
NameRequiredDescriptionDefault

No parameters

TDQS

A4.7/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Beyond the readOnlyHint annotation, the description reveals that it 'loads the feed once to check', which implies a network call despite being read-only. It also assures that 'no cookie or session content is ever returned' and clarifies the --dry-run behavior. These details give the agent important expectations beyond annotations.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is two sentences with no waste. The core purpose is front-loaded, followed by behavioral notes and the no-arguments statement. Every clause adds necessary information.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

For a zero-parameter, no-output-schema tool, the description fully covers what the tool does, how it checks (loads feed), what it returns (status, save time, verification time), and edge case behavior (dry-run). Nothing an agent needs to invoke it correctly is missing.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters4/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

The tool has zero parameters, and the description explicitly states 'Takes no arguments.' This meets the baseline for no-parameter tools, and the statement removes any ambiguity, so a 4 is appropriate.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description states a specific verb ('reports') and resource ('saved LinkedIn session'), and specifies the exact outputs: whether it still works, when saved, last verified. This clearly differentiates it from action-oriented siblings like linkedin_login or linkedin_create_post.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description makes it clear this is a status check tool by stating what it reports. While no explicit alternatives or when-not-to-use are mentioned, the context of sibling tool names (all performing actions) makes the intended usage obvious. A slight deduction for not explicitly saying 'use before other LinkedIn operations'.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

  1. 11 tool updatesv0.1.0
    • First observedlinkedin_apply_to_job
    • First observedlinkedin_create_post
    • First observedlinkedin_list_connections
    • First observedlinkedin_list_pending_invites
    • First observedlinkedin_login
    • First observedlinkedin_scrape_feed
    • First observedlinkedin_scrape_profile
    • First observedlinkedin_search_jobs
    • First observedlinkedin_send_connection_request
    • First observedlinkedin_send_message
    • First observedlinkedin_session_status

TDQS

A4.7/5.0

Scored across 11 tools

Disambiguation5/5

Each tool targets a distinct LinkedIn action (login, session check, create post, send connection, send message, scrape profile, scrape feed, search jobs, apply to job, list invites, list connections). Even similar functions like scraping profile vs. sending connection request are clearly separated by purpose, with no two tools overlapping in behavior.

Naming Consistency5/5

All tools follow the 'linkedin_' prefix plus a snake_case verb_noun pattern (e.g., linkedin_create_post, linkedin_list_connections). Even the outlier 'linkedin_session_status' uses a consistent noun-like phrase, but the naming is uniform and predictable across the entire set.

Tool Count5/5

With 11 tools, the server covers a broad set of LinkedIn capabilities without being bloated. Each tool serves a clear, distinct purpose, from authentication and session management to content creation, messaging, scraping, job search, and application. The count is well within the ideal 3-15 range and feels appropriately scoped for a LinkedIn assistant.

Completeness4/5

The surface covers core workflows: posting, messaging, connection requests, profile/feed reading, job search, and Easy Apply. Minor gaps exist such as accepting/rejecting invitations, editing/deleting posts, or reacting to content, but the available tools handle the most common personal LinkedIn tasks without dead ends.

Maintenance

ActivityMaintained
ResponsivenessNo issues

Related MCP Connectors

Related MCP Servers

  • A
    license
    B
    quality
    B
    maintenance
    Enables full control over LinkedIn profiles through browser automation, allowing reading, editing, adding, removing entries, and publishing posts directly from conversations.
    41
    11 npm
    4
    MIT
  • A
    license
    A
    quality
    B
    maintenance
    Lets an AI assistant operate LinkedIn through an authenticated browser session, enabling profile management, posting, networking, messaging, job search, and automated applications.
    100
    56 npm
    1
    MIT