Skip to main content
Glama
Sundeepg98

linkedin-mcp

by Sundeepg98

linkedin

An MCP server that shows you your own LinkedIn account data as structured tool results instead of pages you have to click through.

Thirty-three tools ship. Twenty-one read. Five write. The other seven are write-shaped, gated, and cannot act at all.

This line said "Fourteen of its seventeen tools read and change nothing. Three write" until 2026-08-31, and it is corrected rather than quietly widened: every one of those three numbers was stale, and the write count was stale in the direction that matters. The numbers above are derived rather than counted by hand -- thirty-three and twenty-one are pinned in tests/test_server_surface.py, five is len(writes.PERFORMABLE), and the seven that cannot act are what is left over. The five are named in The five that write.

It then read "Thirty-one tools ship. Nineteen read" for the rest of that same day, until linkedin_profile_editor_fields was registered. That one is a READ and the write count did not move, which is why the correction is worth one sentence rather than a paragraph -- but it is written down, because the failure this section records is a count going stale by one and nobody noticing.

And it read "Thirty-two tools ship. Twenty read" for the rest of THAT day, until linkedin_my_activity_items was registered -- also a READ, also with the write count unmoved. Two stale-by-one corrections in one day is not a coincidence worth smoothing over: it is what a hand-carried count does, and it is why the sentence above says the numbers are derived and names where.

Until 2026-08-23 this paragraph said "It reads. That is all it does. There is no write path in this repository -- not disabled, not stubbed, not behind a flag." That was true, it was enforced rather than asserted, and it stopped being true the day linkedin_save_job shipped. A README that keeps the comfortable sentence is the first thing a reader trusts and the first thing that misleads them.

What is true now:

  • The package contains exactly one call that can change anything on LinkedIn: a single anchored click in writes.perform. The source scanner still reports it -- it was not taught to stop looking -- and it is admitted by path, function and kind in a one-line allowlist that the tests fail if it widens.

  • Writes are off unless you turn them on. LINKEDIN_ENABLE_WRITES=1, per process. A fresh clone cannot write to LinkedIn at all.

  • Every write is two calls. The first performs nothing and hands you a block to read; the second redeems a single-use token from it. The token is bound to one action on one target and dies in 120 seconds, which makes a scheduled or unattended write structurally impossible rather than merely discouraged.

  • linkedin_unsave_job is built, gated and performable since 2026-08-30. It refused for a month for want of one measured label; see The one that refused, and how it stopped.

  • It does not submit applications, and that is not a shrug. linkedin_job_detail reports apply_path: whether a posting applies on LinkedIn or hands you to an outside applicant-tracking system, and names that system. The identifying half ships as a read. The submitting half is refused for a measured reason -- see Applying: the half that ships.


Read this part before anything else

LinkedIn's User Agreement restricts automated access to the site. That is true regardless of how this server is built, and nothing below changes it.

What this design does is minimise exposure rather than pretend it away:

Choice

Why it lowers the risk

Human-directed only

Every call is one you made, in the moment. Nothing runs on a timer, nothing runs while you sleep.

One action at a time

One page load per tool call. No scroll loops, no auto-paging, no fan-out.

Your own session, your own machine, your own IP

No cookie is exported to any third party. No proxy, no datacentre IP, no headless farm.

An ordinary browser, one flag

No stealth plugin, no user-agent or platform spoofing, no fingerprint patching, no proxy, no timing engineered to imitate a human. One Chromium flag is passed -- --disable-blink-features=AutomationControlled, which stops Blink setting navigator.webdriver -- because LinkedIn checks it at sign-in and refuses one without it. That is the whole of it, it is enforced at launch by readonly.assert_launch_flags_permitted, and tests/test_launch_boundary.py fails the build if a third flag appears.

Your data only

Your profile views, your applications, your saved jobs, your profile, your notifications. No enumerating or harvesting other members.

Reads, except for five named writes

Nothing is sent, posted, endorsed, invited or edited. Saving, unsaving, unfollowing, following and applying are the exceptions: off by default, one at a time, each one confirmed by you against a block built from a live read, with a token that works once and dies in two minutes. This row said "Reads only" until 2026-08-23 and the sentence is corrected rather than quietly widened. It then said "three named writes", listed only saving, unsaving and unfollowing, and opened with "Nothing is applied to" until 2026-08-31 -- corrected the same way, because by then applying and following both shipped and that clause denied one of them outright.

This lowers exposure. It does not eliminate it. Automated access can still result in a rate limit, a challenge, or account action, and that risk is yours to accept. Decide that deliberately before you register the server.

This is not an anti-detection tool, and here is the evidence rather than the assurance

The one Chromium flag above is the sort of thing that makes a repository look like an evasion project. It is worth saying plainly what was measured, because the claim is checkable and the reader should not have to take it on tone.

Audited 2026-08-24 across all 105 tracked files:

  • Fingerprint shaping: zero. No user-agent, platform, locale, timezone, geolocation, viewport-spoofing, device-scale, WebGL or canvas patching; no page.route interception, no injected init script, no extra headers, no proxy. Each was searched for by name across the package and each returned zero call sites. One caveat so a reader who greps is not misled: add_init_script appears twice in readonly.py, both times as the scanner's own pattern for detecting such a call. The scanner names the things it forbids, which is why its source contains them and the rest of the package does not -- partition_mutation_hits confirms it independently, at zero unsanctioned. This audit was taken 2026-08-24, when the sanctioned side was one call. Corrected 2026-09-04: the sanctioned side is now five -- four in writes.perform (click, fill, select_option, set_input_files) and one in dom.activate_messaging_filter (click), read off the live readonly.SANCTIONED_MUTATIONS. The unsanctioned count, the load-bearing half of this sentence, is unchanged at zero.

  • No stealth dependency. Four dependencies, none of them an anti-detection library, and readonly.scan_source_for_evasion returns zero hits across the package -- its only hits anywhere are a deliberately planted control in a test.

  • Timing is fixed, not humanised. Every delay is a constant. import random appears 0 times. The 3-second gap between page loads is MIN_INTERVAL - elapsed, slept exactly -- machine-regular. Randomised jitter is what a tool imitating a human does; a flat interval is throttling.

  • The flag itself is bounded by a gate, not by good intentions: readonly.assert_launch_flags_permitted runs at every launch and tests/test_launch_boundary.py fails the build if a third flag appears.

The flag stops Blink advertising navigator.webdriver, which LinkedIn checks at sign-in and which makes an automated browser unusable for the account's own owner. That is the whole of it. Making an automated browser work and evading detection are different activities, and only the first one is here.

The licence follows from that, and it is deliberately not permissive

This repository is proprietary: all rights reserved, provided for reference, with no permission to use, copy, modify or distribute it.

That is not an oversight or a placeholder. This server drives an authenticated LinkedIn session under a User Agreement that prohibits automation. A permissive licence would invite strangers to point it at their own accounts -- or at other people's -- with the author's name on the repository that told them how.

It is a portfolio artifact. It is meant to be read, not deployed. Read the design, the boundary, the gates and the audit trail; that is what it is for.


Related MCP server: LinkedIn MCP Server

What it can do

All twenty-one reads are here. This table listed FOURTEEN of them until 2026-08-31, and it is completed rather than quietly left disagreeing with the count at the top of this file; the five it omitted were linkedin_login, linkedin_draft_applications, linkedin_new_messages, linkedin_open_messaging and linkedin_surface_census. It also led with linkedin_login_browser, which is the deprecated alias, so the canonical name now leads and the alias is labelled as one.

The twentieth, linkedin_profile_editor_fields, was added later the same day and the count above moved with it in the same edit -- which is the whole discipline this paragraph exists to record. A row added without the count moved is how this table came to be five short.

The twenty-first, linkedin_my_activity_items, arrived after that, on the same day again, and its row and the count moved in one edit for the same reason.

AND THE DISCIPLINE THOSE TWO PARAGRAPHS RECORD HAS SINCE LAPSED, MEASURED 2026-09-05. linkedin_search_appearances has a row above and this count did NOT move to twenty-two, because moving it to twenty-two would make it wrong in a new way. Counted off the live registry rather than off this page: the server registers 38 tools and this table names 27 of them, so eleven registered tools have no row here -- linkedin_comment_on_item, linkedin_compose_fields, linkedin_connections, linkedin_profile_editor_values, linkedin_events_home, linkedin_publish_post, linkedin_react_to_item, linkedin_send_invitation, linkedin_send_message, linkedin_update_profile_field, and this wave's own until the row above was written. Several are writes and are described elsewhere in this file; that is a reason some of them are absent, not a reason the number twenty-one is right.

So the honest state is: the sentence "All twenty-one reads are here" is the same defect its own two paragraphs were written about, and it is recorded here rather than patched, because fixing it means auditing which of the ten belong in THIS table and which are documented in the write sections -- a different job from adding one row, and one that should be done by somebody who reads the whole file rather than as a side effect of a wave that touched one tool.

Tool

Reads

linkedin_who_viewed_me

Who viewed your profile. Where the account has Premium Career this reaches back 365 days -- the highest-intent signal in a job search.

linkedin_search_appearances

The reciprocal of the row above -- that one reads the receiving end of a profile view, this one the receiving end of a SEARCH: how often other people's searches put you in front of them. Your own analytics, no argument, and the address carries no member segment so it can only ever resolve to whoever is signed in. It is the only tool here whose page nobody had opened when it shipped, and its docstring says so rather than letting you find out: the parser was built against a fixture that is SYNTHETIC and labelled so, which proves the tool refuses to publish the third parties put in front of it and proves nothing about whether it reads the real surface. Past the first two number-and-caption pairs the caption is withheld INSIDE the page and never reaches the process -- LinkedIn's breakdown panels describe the SEARCHERS, in exactly the shape a headline metric has, and those are other people's employers and titles. anchors.person is a COUNT of member links and is the field worth reading first: non-zero means LinkedIn's record of a search names the people in it. A zero settles less than it looks like -- zero appearances is equally consistent with "searches leave no record" and "nobody searched for you this week", and this tool cannot separate them; headline: null means no metric was found at all, which is a different answer again.

linkedin_my_applications

Jobs you applied to, with the status LinkedIn shows.

linkedin_draft_applications

The applications you STARTED and never sent -- the tracker tab LinkedIn labels "In Progress" and addresses as ?stage=draft, with title, company, location, how long ago, and the job id. A draft is not a stalled application: nothing went anywhere, so an empty list here is not evidence about anything you did send. It reads the list and nothing else -- the row's own Delete control, and the discard dialog behind it, are never pressed from here. An empty result carries LinkedIn's own tab count, so "you have no drafts" and "this could not be read" are never the same answer.

linkedin_saved_jobs

Jobs you bookmarked.

linkedin_search_jobs

Job search with keywords, location, remote, date posted, experience level.

linkedin_job_detail

One posting in full -- pay range, LinkedIn's applicant count, workplace and employment type, hiring status, and the description. None of these is on a search or saved-jobs card. Also apply_path: which of the two apply routes this posting uses, and for the off-site route, whose applicant-tracking system it would send you to.

linkedin_followed_companies

The company Pages you follow, with the numeric id of each -- which is what linkedin_unfollow_company is addressed by. LinkedIn renders about twenty rows of however many you follow and offers no way to page through the rest, so this reports what it covered rather than implying it covered everything.

linkedin_my_profile

Your own profile: headline, about, skills, and which sections rendered. Experience/Education/Skills are deferred by LinkedIn until the page is scrolled, so they read UNKNOWN rather than zero.

linkedin_notifications

Your notification list, each row carrying unread as LinkedIn had it at the moment of reading. Loading the page clears your unread badge -- exactly as opening the page yourself would, and measured rather than theorised: one call on 2026-08-21 took the badge from 1 to 0 and it does not come back. It cannot be avoided, because LinkedIn marks the list seen on the server when it serves the page; no click, no scroll and no mark-as-read call is involved anywhere in this package, and the only way not to clear the badge is not to call this. The per-row flag is the one fact the page load destroys, which is why it is captured.

linkedin_new_messages

Whether anything has ARRIVED since you last opened Messaging, read off the badge in the feed's nav. One page, and it opens no conversation and never loads the messaging surface at all. This is not an unread count: LinkedIn's badge counts new-since-last-visit and resets when the Messaging tab is opened, so a 0 here means nothing has landed since your last look and never that your inbox is clear -- measured with a genuinely unread recruiter InMail on screen. A badge that did not render comes back null, which is not zero and is never reported as zero.

linkedin_open_messaging

Your conversations, each one's unread flag paired to its own row rather than reported as a count beside a list of names. It opens a thread, and that is why the cost is in the name: asking LinkedIn for the messaging surface does not stay on a list, it redirects into ONE conversation LinkedIn chooses, measured twice -- and opening the tab resets the new-since-last-visit badge that linkedin_new_messages reads. Whether opening marks that message read is an honest UNKNOWN after three attempts, because the only signal that would settle it requires performing the act being measured; if the sender has read receipts they may see one. The count is a floor, not a total. Correspondents' names are off by default and the thread identifier in the landed url is always redacted. message_filter activates one of seven named pills -- inmail among them, since those pills are buttons with no href and no url can reach them -- and anything outside that fixed set is refused, not clicked.

linkedin_auth_status

Whether there is a live session, measured by an authenticated request.

linkedin_login

Opens a browser window at LinkedIn's sign-in page for YOU to type into; nothing is automated and this server never sees, types, stores or transmits a password. The canonical name since 2026-08-25, matching naukri_login, instahyre_login and uplers_login in the same family. The window stays open until the identity endpoint confirms a real session, you close it, or the wait runs out -- a cookie appearing only causes the endpoint to be asked again, and a timeout reports authenticated false with a reason rather than an optimistic success. There is no reauth here and that is deliberate rather than missing: LinkedIn issues this server no refresh token, so a linkedin_reauth would be this tool under another name.

linkedin_login_browser

DEPRECATED ALIAS for linkedin_login, which it forwards to. Identical behaviour, and kept working because things already call it -- removing a name that used to work is the worse failure. There is no plan to remove it.

linkedin_session_info

Whether the session is live and when it lapses, read from the browser's own cookie jar. Reports the credential, the csrf cookie that supports it, durability, and why no silent reauth exists here. renewal.session_lapses_at is the date past which no renew can help and you sign in by hand -- the field to compare across servers, and on LinkedIn it equals the cookie's own expiry because nothing here can carry the session past it.

linkedin_logout

Ends the local sign-in by erasing this machine's cookie jar. The one destructive tool here: confirm=False (the default) performs nothing and previews what would go. Issues no request, so LinkedIn is never told.

linkedin_cdp_status

Recovery diagnostic: is there a Chrome this server could attach to? Touches nothing on LinkedIn.

linkedin_server_info

The boundary, the rate settings and the launch flags, without reading the source.

linkedin_surface_census

An instrument for extending this server, not a job-search tool -- its own docstring leads with that, and no answer about finding, comparing or tracking a job is in here. It measures what controls one page carries, so a capability this server has never built can be costed from what the page really holds instead of from a guessed selector found to be wrong at the moment it would fire. It takes a KEY and never a url, from a fixed set of five: feed, profile, profile_edit_intro, settings, settings_dark_mode. One page load, and it clicks nothing. It reports SHAPES and never names, so it identifies no member. Absent means UNKNOWN, never zero -- this server does not scroll, so a count describes the first render and nothing below the fold. A control being present is not evidence that using it is safe. Notifications, the network page and messaging are deliberately not offered: loading them costs a badge or opens somebody's conversation, and a census is not worth a side effect.

linkedin_profile_editor_fields

The second instrument, and the one tool here that publishes control NAMES. It names the controls inside the intro editor on your own profile -- which linkedin_surface_census will not do, because the census reports shapes and returns <opaque> for any name failing its length or character gate. That gate is what makes the census safe to point at a page full of strangers, and it is why linkedin_update_profile_field cannot name a field to type into. This tool relaxes it on ONE ground and establishes that ground per call: it loads /in/me/, requires LinkedIn's own isSelfProfile=true on the landed url, loads the intro editor, and requires the same member segment on both -- and if either half fails it returns a refusal carrying no field data at all, so a refusal can never be read as "there are none". The container is found structurally, as the nearest dialog ancestor of the control named Save, never by an index; two such controls or none is a refusal rather than a guess. It takes NO ARGUMENT, so no caller can aim it at another page. Two page loads, and it clicks nothing. LABELS, NEVER VALUES -- a label is "First name", a value is your first name, and no value and no href leaves the page. Your member slug is compared and discarded: it is in no part of the answer.

linkedin_my_activity_items

The item keys, for your own posts only -- which nothing else here returns, and which is why linkedin_comment_on_item and linkedin_react_to_item are registered and refusing: neither was ever blocked by the read boundary or by the click anchor, they simply had nothing to aim at. linkedin_surface_census cannot supply one by construction, since it substitutes every urn out before it counts, and the feed carries zero item permalinks. It reads /in/me/ and takes NO ARGUMENT, so no caller can aim it at another page. Authorship is established, not inferred from where an item sits, and it takes all three of: LinkedIn's own isSelfProfile=true on the landed url; one single author name across every item overflow control on the page, so a rail carrying somebody else's item is refused outright; and that name standing in a prefix relation to the page's own h1. If any of the three fails there is no items key at all, so a refusal can never be read as "you have no posts". No name ever leaves the page -- the comparison happens inside the document and only booleans come back. A urn is published only if it matches the exact urn:li:<type>:<digits> shape and sits inside an item root that itself carries an overflow control; anything else is counted and dropped. The output is real identifiers: do not paste one into a tracked file in this repository, which is public and swept for exactly that shape.

The six that write

This heading said "The three that write" over a three-row table until 2026-08-31, stale by two: linkedin_apply_job shipped 2026-08-25 and linkedin_follow_company 2026-08-30, and neither had been added here. It went to five that day and to SIX later the same day, when linkedin_update_setting became performable. The count is len(writes.PERFORMABLE) and it is pinned against these tool names in tests/test_server_surface.py, so the table below cannot fall behind the server again without that test failing.

Tool

What it does

linkedin_save_job

Bookmarks one posting. Call it with no confirm_token and it performs nothing: it reads the posting and your saved list live and returns a block naming the job by title and employer, which way the toggle would move, where each fact came from, and how to undo it. Call it again with the token from that block to act.

linkedin_unsave_job

Same shape, same gates, and it acts -- since 2026-08-30, when the label its click anchors on was finally measured. It still refuses from any state it does not recognise, and its preview is currently blocked by a separate defect in the Saved-tab read. See below.

linkedin_unfollow_company

Stops following one company Page. Same shape and the same five gates. Addressed by the numeric company id, never by name -- names collide, change, and are not yours to rely on, and the click is anchored to the row carrying the id, so what you name and what gets pressed are the same row by construction.

linkedin_apply_job

Submits an application to one LinkedIn-hosted posting, since 2026-08-25. Same two-call gate plus a second one: the apply modal is re-read before the submit is pressed, and it is only pressed if exactly one control carries LinkedIn's own submit hook and zero advance controls are present. Off-site postings are reported, never driven. This is the one write nobody has established LinkedIn can undo -- the honest form is stronger than "this server cannot withdraw it".

linkedin_follow_company

Follows the company that posted one job, from the posting page itself, since 2026-08-30. Same shape and the same five gates; the direction is read off the posting at no extra page load. A follow is reversible on LinkedIn. This row used to end "but this server cannot aim the undo ... with nothing resolving one to the other", and since 2026-09-05 that is half wrong: linkedin_job_detail returns company_id, the employer's numeric Page id, read off the canned people-search link in LinkedIn's Premium insights panel on the same posting. So the undo can be aimed from the posting you followed from -- when that panel renders. It is Premium and LinkedIn draws it for some employers and not others, so company_id.state is often absent, and reversible_by on the preview is still written against the weaker fact.

| linkedin_update_setting | Changes ONE named account setting -- dark mode, and nothing else -- since 2026-08-31. The first write here that touches neither a job nor a company Page. Same two-call gate; the destination is NAMED rather than derived, because the setting has three states and no direction can be inferred from two, and the token binds to the destination as well as the setting. The control clicked is the radio named for where you are going, and the SELECTOR IS BUILT FROM THE ROLE THAT CONTROL ACTUALLY CARRIES rather than an assumed one. Verified by a fresh navigation and a re-read of the whole group's own checked property, which is the strongest verification in this package. It is also the cheapest write here: dark mode is a per-account display preference with no audience, observable by nobody, and the same tool sets it back. Asking about any other setting loads NOTHING. |

Seven more tools are write-shaped and cannot act at all: publish_post, comment_on_item, react_to_item, update_profile_field, set_open_to_work, send_invitation and send_message. Each holds a full spec and reads its own surface live when previewed, then refuses with what it just saw and the one measurement that would complete it. None is in writes.PERFORMABLE, none holds a url_template, and writes.mint refuses each of them a grant at issue -- so no confirm token for any of them can exist. They are on the surface because a tool that names its own missing measurement is correctable and a silence is not.

After the click, the result is confirmed from a different surface -- your saved list, with LinkedIn's own per-tab count -- rather than from the button that was just pressed. performed comes back true, false, or "unknown". On "unknown", do not retry: a retry on a toggle that did land performs the opposite action.

The one that refused, and how it stopped

This section described a permanent-looking refusal for a month. It is kept as the worked example, because how it ended is more useful than that it ended.

LinkedIn identifies the save control by its accessible name. Every capture this repo holds -- four postings, both hydration states, two different days -- shows aria-label="Save the job", the unsaved state. The name it wears when a posting is saved had never been observed, and it could not be observed by reading: there was nothing saved on the account to observe it on. So linkedin_unsave_job had no anchor, and this server would not guess one -- "Saved" and "Unsave the job" were both plausible and it had seen neither.

That was circular: the only way to see the label was to perform the action its inverse gated.

What broke it, on 2026-08-30. The operator authorised a save. perform read back the label the control changed into and reported "Unsave the job". The prediction held exactly -- one row of a table, not a missing code path.

And one reading was not enough to write it down. A label reached by performing its own inverse can only be re-measured by performing it again, which makes it a measurement nobody can afford to check. So the row waited for a read-only route: linkedin_job_detail now reports save_state off the control on a posting it has already loaded, for no write and no extra page load. Three further readings through it agreed with the first. The row went in on four observations across two independent routes.

Which label it was mattered too. "Unsave the job" names its own inverse; the measured OFF row establishes that this control is named for the ACTION it performs, not the state it is in. "Saved" would have been ambiguous between the two readings, and a label mapped to the wrong state points a click at the opposite action. Had the measurement come back "Saved", the row would still be missing.

What still refuses. The refusal narrowed rather than disappearing: unsave_job acts only from a state it recognises. And it is not yet reachable end to end -- its preview takes its direction from your Saved tab, and that list currently cannot be read (the rows draw; the harvest returns none of them). The capability is real; the route to it runs through a broken read.

The general form, which is the reason this section survives: when a measurement can only be bought with an irreversible act, the next thing to build is not the row -- it is the cheap way to take that measurement again.

Applying: the half that ships, and the half that does not

linkedin_job_detail tells you how a posting is applied to. LinkedIn draws the apply control as a link rather than a button, so its destination is legible without touching it, and apply_path reports one of three answers:

  • linkedin_apply -- the application is filled in and submitted on LinkedIn.

  • offsite -- LinkedIn hands you to the employer's own applicant-tracking system. The destination is decoded out of LinkedIn's outbound wrapper by string alone: no redirect is followed and no third-party host is contacted. You get the host, so you know whose form you are about to fill in.

  • unknown -- it would not say. This is a real answer and it is the important one; see below.

That is the useful half, it costs no extra page load, and it is a pure read.

It does not submit. Not because applying is beneath this server's remit, but for reasons that were measured:

  1. The apply FLOW has never been captured. Across thirteen job captures there are zero forms, zero file inputs, zero dialogs, zero screening questions and zero controls that submit anything. Nothing here has seen what would be filled in or pressed. This is the same standard unsave_job is held to, applied to the action that deserves it most.

  2. An application cannot be undone from here, at any confirm level, in any circumstances. Withdrawing is permanently forbidden.

  3. The off-site half is not this server's to do at all, however good a capture got. Driving somebody else's form, on somebody else's domain, under their terms, is a different piece of software.

apply_job is therefore fully specced and gated in writes.py, registers no tool, and holds no url, so a grant for it is refused at issue rather than at use.

And the gap has an address, which is what makes it unmeasured rather than permanent. scripts/_probe_apply_flow.py captures the LinkedIn-hosted flow and inventories exactly the controls every existing capture lacks -- forms, file inputs, dialogs, screening questions, the control that submits. It reaches the flow by navigation, not by a click (LinkedIn draws the apply control as a link), the package's own mutation scanner finds zero mutating calls in it, and it takes the job id as a required argument so no default picks a posting for you. It also reads LinkedIn's own applied-tab count before and after, because opening an Easy Apply flow may create a draft -- a hypothesis nobody has verified, labelled as one, and measured rather than assumed.

It has not been run. Run it with somebody watching, on a posting whose apply_path reads linkedin_apply.

Why the classifier demands several fields agree, when one obvious field looks sufficient. Each candidate was measured and each fails alone: data-view-name="job-apply-button" is present on one capture in thirteen and absent from a fully hydrated off-site posting, so its absence carries no information at all. The outbound wrapper is generic -- one capture holds two of them and only one is the apply control. The accessible name is the strongest single field and is the one LinkedIn has already changed: the string "Easy Apply" appears in zero accessible names, and twice in prose on the same page, so a parser keyed on the name everybody knows the feature by matches nothing. And the pre-hydration payload is worse than useless -- an off-site posting was measured carrying the on-site flow's own marker, for the same job id, because LinkedIn ships the whole apply state machine as a per-posting template.

What it deliberately cannot do

THIS SECTION WAS REWRITTEN ON 2026-08-31 RATHER THAN PATCHED, and the reason is worth the two lines. It listed following a company and submitting applications among the things this server deliberately cannot do, while both had shipped -- one six days earlier, one the day before -- and it said a follow "is still not performable" three paragraphs after a table row describing it performing. Patching individual sentences in a section whose premise has moved produces a section that contradicts itself in more places, which is what the last three attempts at it did. What follows is organised by WHY a thing is refused, because that is the axis that actually predicts whether it will ever change.

Sending a message or an InMail. Publishing a post. Commenting. Reacting. Editing a profile field. This paragraph described all five as registered, specced and refusing, blocked by the same measured thing -- that they would have to TYPE, and no typing kind was sanctioned anywhere in this package -- and that stopped being true on 2026-09-01. It is corrected in place rather than deleted, because the section it sits in is organised by WHY a thing is refused, and this paragraph is now the record of a refusal that ENDED rather than a live example of one.

fill was sanctioned inside writes.perform on 2026-09-01, and select_option on 2026-09-02. All five of these now perform, behind the same two-call token gate as every other write in this package -- verified 2026-09-04 against the live writes.PERFORMABLE, which carries send_message, publish_post, comment_on_item, react_to_item and update_profile_field alongside the rest. None of the five was handed a new refusal to replace the old one: the sentence this paragraph used to close on -- "perform may CLICK -- that is one entry ... and it may do nothing else" -- is false in both halves now. perform may CLICK, FILL and SELECT, three entries and not one, and "no measurement and no url admits any of these five" does not hold for a single one of the five any more.

Endorsing a skill is IMPOSSIBLE rather than refused, and it is the only one of these where the refusal is on somebody else's behalf. The control exists only on a third party's profile, and linkedin_who_viewed_me reads the RECEIVING end of exactly that signal: loading a stranger's profile leaves them a durable record, 365 days deep. That cost lands on a person who did not agree to it, so it is not the operator's to clear.

Sending a connection invitation is refused on something narrower than a boundary. The route costs no badge -- the invitation controls are on his own profile -- and the aiming works: a word he supplies is handed INTO the page, the comparison happens there, and three integers come back, with exactly one match aimable and two or more refused as ambiguous. What stops it is that the confirm block cannot name the person. The aiming is safe precisely because no label enters this process, so the block can say "one of nine controls carries your word, at position three" and cannot say who. Every other write here names its target in terms you can check.

Marking notifications read is not offered, and the honest form is that there is no way to avoid it rather than that it is refused: LinkedIn marks the list seen on the server when the page is served. See the side effects below.

Open To Work has no url at all -- re-measured 2026-08-31, its control is a button with no href -- so its editor opens as a modal, and the single click that would first SHOW it is also the first click that could CHANGE it. It is the one setting here a current employer can see.

Collecting data about other members is out of scope by construction rather than by policy. linkedin_surface_census reduces every accessible name and every href to a SHAPE before counting, so a row identifies a kind of control and never a person, and a shape seen exactly once has any run of capitalised words blanked.

Reading your own inbox is neither refused nor unmeasured any more, and this paragraph said it was both. The boundary was narrowed on 2026-08-26 on the operator's ruling and linkedin_open_messaging and linkedin_new_messages ship. The hypothesis the old text called unverified has been MEASURED, twice, and it was right: /messaging/ does not stay on a list -- LinkedIn redirects it into one conversation of its own choosing, so the load opens somebody's thread and resets the nav badge. That cost is now stated in the tools' own names and docstrings rather than denied by a list that could not enforce it. /messaging/compose stayed forbidden through that narrowing and is admitted today as ONE exact url, by an exemption, for a capture that has not been taken.

Anything else that would change something on LinkedIn's servers is out of scope, and tests/test_readonly.py fails the build if a mutating call appears anywhere in the package outside the entries SANCTIONED_MUTATIONS names -- five of them, read off the live module 2026-09-04. This line said "two" and is corrected in place rather than left carrying a bare number that goes stale at the next widening.

One tool changes something on this machine: linkedin_logout(confirm=True) erases the local cookie jar. It issues no request, so LinkedIn is never told, and linkedin_server_info names it under local_state_writes rather than folding it into the read_only field.

The side effects, stated rather than hidden

This heading said "The two side effects" over a list that had grown, which is the same rot as the write count and is worth naming rather than silently renumbering. A read that changes something has to say so:

  1. Opening the notifications page clears LinkedIn's unread badge -- exactly as it would if you opened the page yourself. Measured, not theorised: one call on 2026-08-21 took the badge from 1 to 0, and it does not come back. It cannot be avoided: LinkedIn marks the list seen on the server when the page is served, so there is no read of this surface that leaves the badge alone. No click, no scroll and no per-item open is involved, and there is no mark-as-read call anywhere in the package. The only way not to clear the badge is not to call linkedin_notifications. Since the badge is going either way, each row carries unread as LinkedIn had it at the moment of reading -- the one fact the page load destroys.

  2. Running a job search adds to your own recent-search history, the same as typing the query on the site.

  3. Opening messaging clears the messaging badge AND opens one conversation LinkedIn chooses -- measured twice. /messaging/ does not stay on a list. Only linkedin_open_messaging and linkedin_new_messages can incur this, and only when called; linkedin_send_message deliberately does not open messaging at all -- it reads the nav badge off a page already loaded, and refuses.

  4. Three census surfaces may cost something merely by being opened, and each returns a cost field saying what. The two publishing composers can autosave a draft this server has NO REACHABLE SURFACE to detect -- 17 candidate draft addresses were run against the read boundary and all 17 were refused -- and the message composer is on the surface point 3 describes. Every other census key still renders state and leaves nothing.

Each is disclosed in the tool docstrings and in linkedin_server_info.

THERE IS NO COUNT IN THAT SENTENCE AND THAT IS DELIBERATE. It said "both" over a list of two, then stayed "both" while the list grew, then said "all four" for about an hour. A count in prose beside a list is a second enumeration of the same thing and it goes stale the moment somebody adds to the list without reading the paragraph above it -- which is exactly how the write count in this file was wrong four times. The list is the enumeration; known_side_effects in linkedin_server_info is the machine-readable one, and tests/test_server_surface.py is what pins the numbers that ARE countable.


Setup

cd D:\workspace\projects\job-hunting\mcp-servers\linkedin
pip install -r requirements.txt
playwright install chromium
python -m pytest            # 2304 passed

Then, once the server is registered with a client, call linkedin_login_browser first. A window opens at linkedin.com/login. Sign in there yourself -- this server never sees, types, stores or transmits a password. The persistent Chrome profile keeps the session afterwards, so this is a one-time step until LinkedIn expires it.

Confirm with linkedin_auth_status before trusting any read.

Registering it

stdio transport, entry point linkedin.py:

{
  "mcpServers": {
    "linkedin": {
      "command": "python",
      "args": ["D:\\workspace\\projects\\job-hunting\\mcp-servers\\linkedin\\linkedin.py"]
    }
  }
}

How "read-only" is enforced rather than asserted

linkedin_server/readonly.py holds four mechanisms, and the tests show each of them failing on a planted violation before trusting it on the real package. A check that cannot fail certifies nothing.

  1. A navigation allowlist. assert_read_url is the only door to page.goto. Every permitted url is an anchored pattern; a keyword you type cannot become a navigation to an action url. Blocked targets include /jobs/application/, /messaging/, invitations, /edit/, open-to-work, anything with action=, and every host that is not www.linkedin.com. The job-posting pattern is the tightest on the list: it admits a numeric id and no query string at all, because the url is built from an integer and so never has one to preserve. The slug form LinkedIn also serves (/jobs/view/senior-node-engineer-at-acme-4600000042) is refused for the same reason -- a slug is a job title, and a title is a string.

  2. A source scanner. The package is grepped for calls that could change state -- click, fill, type, press, select_option, set_input_files, form submission, and any non-GET request. This said "exactly one" until 2026-09-04 -- and the verb list two lines up had already named set_input_files before that correction, so this paragraph had been internally inconsistent with itself since before today. It finds five, read off the live readonly.SANCTIONED_MUTATIONS: four inside writes.perform (click, fill, select_option, set_input_files) and one click in dom.activate_messaging_filter.

    The scanner was not relaxed to accommodate any of them. It still reports every mutating call unconditionally, and what admits these five is a separate one-line allowlist, readonly.SANCTIONED_MUTATIONS, keyed on (path, function, kind). Six near-misses are shown failing, not five -- corrected 2026-09-04, counted directly off tests/test_readonly.py::test_the_exception_does_not_widen: the sanctioned click in the wrong FILE (dom.py), the sanctioned kind in the wrong FUNCTION of writes.py, two wrong KINDS inside perform itself (page.type and page.press -- a fill inside perform stopped being one of these on 2026-09-01, when it was sanctioned and the test case was re-aimed from page.fill to page.type rather than deleted), a click buried in a closure one scope down (attribution is to the innermost enclosing function, so the closure is named as itself and inherits nothing), and a click at module level with no enclosing function at all. The package is separately asserted to contain exactly as many mutating calls as the list has entries, which is what catches a second click inside perform that the triple alone cannot distinguish from the first.

    The fifth kind, set_input_files, is bounded by linkedin_server/uploads.py rather than by this allowlist -- a declared root, a refusal on any symlink in the chain, a regular-file check, and a digest read at preview and re-read before the browser is handed the file; the allowlist controls the WIDTH of the opening, the guard controls what comes through it.

    evaluate is flagged too: the three read-only DOM harvesters waive it with a trailing # readonly-ok, so any new evaluate fails the build until somebody waives it in a reviewable diff.

  3. A tool-surface check. No tool name contains a write verb, and no tool docstring makes an affirmative write claim. Docstrings may still say what a tool cannot do -- "has no way to add or remove anything" is the sentence a read-only tool should contain, so the check looks for negation rather than banning the words.

  4. A launch boundary. assert_launch_flags_permitted refuses any Chromium flag outside the two sanctioned ones, and refuses --disable-blink-features carrying any value but AutomationControlled -- that flag can switch off arbitrary Blink behaviour, so permitting the name is not enough. browser.py runs it before every launch, so it binds at runtime and not only in CI. A companion scanner rejects an anti-detection library arriving as a dependency (playwright_stealth, undetected_chromedriver, captcha solvers, TLS-spoofing clients), matched on import lines only so this file can go on describing the boundary in prose.

The injected scripts are scanned separately for anything that could mutate the page (.click(, .value =, dispatchEvent, fetch(, ...). They query the DOM and read text.

That scan is bound to what actually runs, not to what is named a certain way. The tests parse the package, find the first argument of every page.evaluate(...) call, resolve it to its module-level constant and scan that -- so a script cannot be injected without being read, and one this check cannot resolve (assembled at runtime, say) fails the build outright. The earlier version scanned a hand-written list of three names ending in _JS; a cold review put a constant called EVIL_INLINE, carrying localStorage.setItem and fetch(, through the existing call site and shipped it with every test green. That hole is closed, and the attack is now a test.

A sibling server shipped the opposite of this the day before this one was built: it reported success the moment a session cookie appeared. LinkedIn hands cookies to signed-out visitors too, so that success meant nothing.

Here, the verdict comes from GET /voyager/api/me -- the identity call LinkedIn's own web app makes on page load. A li_at cookie appearing is only ever a reason to ask the endpoint again.

Three outcomes are reported, not two:

  • authenticated: true -- the endpoint returned an identity.

  • authenticated: false -- the endpoint refused, or the feed redirected to the signed-out wall.

  • authenticated: null -- neither could be established. Unknown does not collapse into "signed out", or the server would tell you to sign in again while your session was perfectly fine.

Corroboration can only ever turn an unknown into a false. It is never allowed to manufacture a true on weaker evidence.

Cookie values are credentials: they are never logged, never persisted by this server, and never appear in a tool result. Only their presence is reported, and two tests assert that.

Signing in, and how long it lasts

Call linkedin_login_browser. A Chrome window opens at LinkedIn's sign-in page and you type into it. This server never sees, types, stores or transmits a password -- there is no code path that could. The window stays open until the identity endpoint confirms a real session, you close it, or wait_seconds elapses (300 by default; pass a larger number if you need longer).

It is a one-time step, not a per-session one. The session lives in an on-disk Chrome profile under _state/chrome-profile/, so it survives:

Event

Session survives?

Why

This server restarting

Yes

The session is on disk, not in the process.

The machine rebooting

Yes

Same.

The profile directory being deleted

No

That directory is the session.

Signing out inside the window

No

LinkedIn revokes it.

LinkedIn expiring the cookie

No

See below.

How long LinkedIn gives you. linkedin_session_info reports the li_at cookie's expiry date and the days remaining, read live from the browser's own cookie jar -- so you never have to guess, and it is a measurement rather than a claim in a README. For calibration, LinkedIn's own long-lived cookies in this profile (bcookie, bscookie) were issued with a 365-day expiry. The li_at figure is the one that governs the login, and only a real sign-in can produce it.

Cookie values are credentials: never logged, never persisted by this server, never in a tool result. Only the name, the presence and the expiry.

When it lapses, every read tool says so -- {"error": "not_authenticated", "message": "..."} naming linkedin_login_browser as the way back. It never returns an empty list instead; an empty list from an expired session is indistinguishable from an empty list because you genuinely have none.

The cold start, and the trap in it

li_at is a persistent cookie. JSESSIONID -- which LinkedIn's own web app copies into the csrf-token header, and without which the identity endpoint will not answer an authenticated request -- is a session cookie (is_persistent=0 in this profile's cookie store). So every time the browser starts, the jar holds a perfectly good login and no csrf token.

A server that asked the identity endpoint straight away would send a request with no token, be refused, and tell you to sign in again while your session was fine. So on a cold jar check_auth loads one LinkedIn page first, which makes LinkedIn issue the cookie, and only then asks. That load doubles as the corroborating read, so it costs no extra request.

The recovery path: attaching to your own Chrome

Not the daily path. The persistent profile above is the answer; this is the fallback for the day that profile's session dies and a fresh sign-in is being refused. Enable it with LINKEDIN_CDP_ATTACH=1 and this server launches nothing -- it attaches over CDP to a Chrome you started.

Two things silently defeat this, both measured on this machine:

  1. A Chrome opened from the taskbar has no DevTools port. "My browser is open" is not enough; it has to have been started with --remote-debugging-port.

  2. Chrome's singleton eats the flag. If any Chrome is already running, starting a second one with the flag hands the arguments to the first and exits -- no port, no error, exit code zero.

So either quit Chrome completely first (windows and the background instance), which keeps your real profile and therefore your real LinkedIn session:

"C:\Program Files\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9224

or give it a profile of its own, which works alongside your running Chrome but is signed into nothing, so you sign in to LinkedIn once inside that window:

"C:\Program Files\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9224 --user-data-dir="%LOCALAPPDATA%\linkedin-cdp"

Confirm it worked by opening http://127.0.0.1:9224/json/version -- JSON means the port is live. Or call linkedin_cdp_status, which probes it for you and reports the command when nothing answers. The address is 127.0.0.1 and not localhost: Chrome binds the port on IPv4 only, so the name resolves to [::1] first and eats a timeout (measured at 2085 ms against 35 ms).

Port 9224, deliberately not the sibling Naukri server's 9223.

In attach mode this server takes no profile lock (it owns no profile), works in a tab of its own rather than driving one of yours, and on teardown disconnects without closing your browser -- Playwright's close() on a CDP connection only drops the client, which was measured against a real Chrome before it was relied on. The read-only allowlist is identical in both modes.

Rate discipline

  • A flat 3-second minimum between page loads, enforced globally. Throttling, not disguise: it is deliberately not jittered to resemble anything.

  • One page load per tool call. The only exception is linkedin_my_profile(include_skills=True), which loads a second page and reports pages_loaded: 2.

  • No auto-paging. Ask for the next page of a search deliberately with start=25. Every list result carries capped, page_had and limit, so "25 results" is never mistaken for "25 results exist".

  • One call at a time, serialised in-process; one process at a time, serialised by a cross-process lock on the Chrome profile. Two processes on one Chromium user-data dir corrupt it and your session is gone -- that cost a sibling server 37 minutes.

  • The window does not linger. The browser closes after 5 idle minutes and releases the lock.

When something cannot be read

The server raises instead of returning an empty list. An empty list from a page that failed to render is indistinguishable from an empty list because you genuinely have none, and those two must never be confusable. A failed read comes back as {"error": "extraction_failed", "url": ..., "hint": ...} so you can open the same page yourself and see what it saw.

The one exception is linkedin_search_jobs, where zero results is a real answer; it returns results: [] with a note.

How the pages are read

LinkedIn's class names are generated and its GraphQL query ids rotate with every deploy, so both make brittle anchors. What does not rotate is the shape of a link: a person is behind /in/<slug>, a job is behind /jobs/view/<id>. Every list surface is harvested by finding those links and reading the text of the card around them, then parsed by pure functions in shape.py -- which is why the parsing is tested without a browser, a network or an account.

Notifications is the one surface with no dependable per-item link, so it is anchored on structure instead. It is the most likely to need updating, and it raises rather than returning an empty list when it misses.

Layout

linkedin.py              entry point (stdio)
linkedin_server/
  config.py                  paths, timeouts, caps, the rate floor,
                             the two launch flags
  readonly.py                the allowlist, the scanners, the verb list,
                             the launch boundary
  profile_lock.py            cross-process lock on the Chrome profile
  browser.py                 persistent context, single-flight, idle close
  auth.py                    the login gate, session lifetime, cold start
  cdp_bridge.py              the recovery path: attach to a running Chrome
  dom.py                     the read-only harvesters and the control readers
  shape.py                   pure parsers and the result envelope
  server.py                  the thirty-three tools
  errors.py
tests/                       1393 tests, no network, no account
  fixtures/                  frozen LinkedIn markup, scrubbed

Status

Built and tested: 1393 tests, no network and no account. Most run with no browser at all; the fixture-driven modules launch a local headless Chromium to run the real readers over frozen markup, which reaches nothing outside the machine.

These counts are the ones this file has most often had wrong. They said 986 for three waves after the suite passed a thousand, which is harmless on its own and is the same habit that let four documents go on saying this server could not write. They are re-measured at each wave now rather than carried forward.

First live run: 2026-08-21. Sign-in succeeded and the session persisted, so the flag above is now verified sufficient on this machine, and /voyager/api/me and the li_at lifetime (365 days) are confirmed. Every read tool was then run once against the real account. Four of the eleven worked; the sweep is written up in ../_audit/2026-08-21-linkedin-parse-fix.md, and this is what it found.

linkedin_who_viewed_me was returning names that were not names. Every row carried the page heading, "Who's viewed your profile", attached to a real person's profile link -- four rows, one repeated name, all four links genuine. It was fixed the same day: the row boundary no longer depends on an attribute LinkedIn attaches after hydration, privacy-limited viewers are no longer silently dropped (they were six of ten), and the timestamps are read. Verified live: 10 rows, 10 distinct names, none missing a field.

Second pass, 2026-08-22. The three surfaces that pass left broken were repaired and verified live. All four defects had the same shape: a reader anchored on markup LinkedIn no longer emits, or on markup whose presence depends on how far the page had rendered.

tool

was

now

linkedin_my_profile

errored: no name could be read

reads name, headline, location, About and photo from a page with zero h1. A section is now the largest ancestor of its heading holding exactly ONE heading -- the same rule the row walk uses -- which gives identical output pre- and post-hydration. Verified live.

linkedin_saved_jobs, linkedin_my_applications

errored on a redirect

read /jobs-tracker/?stage=saved and ?stage=applied. Both lists are genuinely empty, and an empty result now says so explicitly, with LinkedIn's own tab count and the empty-state wording. A zero the page does not corroborate is still an error. Verified live.

linkedin_notifications

rows, with noise

screen-reader text is subtracted by count rather than by phrase, and when comes from the card's own time element. Each row also carries unread as it stood when read. Verified against a frozen capture of the live page.

skills, inside my_profile

returned All, Industry Knowledge, Tools & Technologies

returns the real list -- 20 skills on the live account -- keyed on the only per-skill anchor the page offers.

One thing the profile reader will not do: Experience, Education and Skills are not on the profile page at all. LinkedIn defers them until it is scrolled, and this server does not scroll. They are reported as UNKNOWN, never as zero, and details_urls gives you the page for each.

Third pass, 2026-08-22. linkedin_search_jobs was the last broken tool. On a row for a verified employer LinkedIn adds a screen-reader line reading " with verification"; read positionally, that line became the company and pushed the real company down into location -- 5 of 14 rows across two live searches.

The fix is not a rule about that string. Fields are no longer read as "line 1, line 2, line 3", because any line LinkedIn inserts shifts every field after it, and the same two pages carried "Promoted", "Apply", "Viewed", "Actively reviewing applicants", a salary chip and an alumni line. Each field is now anchored on the thing that IDENTIFIES it: the title on the text of the link that makes the row a job row, with the page's own screen-reader copies subtracted by count; the company on the accessible name LinkedIn gives the employer's logo, which is an image and so cannot be moved by a line; the location on the metadata list inside the entity lockup, where the lockup is found without any class name as the smallest ancestor of the link that also holds that logo. A surface offering none of those -- the job tracker offers none -- falls back to reading lines in order, as before.

Verified live on the same query: 7 of 7 rows agree with LinkedIn's own artdeco-entity-lockup elements, which the fix deliberately does not use, and 3 of those 7 carried the verification decoration. The tests inject a decoration LinkedIn has not shipped at every position in every frozen row and require the answer not to move, with a control that shows the same injection breaking the fields once the anchors are taken away.

Available Tools

17 tools
linkedin_auth_statusA

Report whether there is a live LinkedIn session, measured not guessed.

The verdict comes from an authenticated request: GET /voyager/api/me, the same identity call LinkedIn's own web app makes on page load. A session cookie sitting in the profile proves nothing and is never treated as an answer -- LinkedIn hands cookies to signed-out visitors too.

Three outcomes, deliberately:

  • authenticated true -- the endpoint returned an identity.

  • authenticated false -- the endpoint refused, or the feed redirected to LinkedIn's signed-out wall.

  • authenticated null -- neither could be established. Unknown is reported as unknown rather than collapsed into "signed out".

Costs up to two requests: the identity call, plus one feed load used only to turn an inconclusive answer into a definite "false".

ParametersJSON Schema
NameRequiredDescriptionDefault

No parameters

Output Schema

ParametersJSON Schema
NameRequiredDescription

No output parameters

TDQS

A4.7/5.0
Behavior5/5

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

With no annotations provided, the description carries the full burden and does so excellently. It discloses the internal behavior: makes up to two requests, uses the identity endpoint, treats cookies as unreliable, and explicitly defines the null case as 'unknown' rather than 'signed out'. This level of detail is transparent and goes well beyond basic expectations.

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 structured with a clear opening statement, followed by detail and bullet-point outcomes. Every sentence adds value: it explains the measurement method, the meaning of each outcome, and the cost. It is thorough without being verbose, and the front-loaded core statement makes it easy to grasp quickly.

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 that the tool has no parameters, an output schema exists, and the description thoroughly explains the semantics of each outcome, nothing is missing. It also covers edge cases (e.g., cookies from signed-out users) and costs, making it complete for an agent to use 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 has zero parameters, so the description correctly does not discuss any. Per the calibration rules, a baseline of 4 is appropriate since there are no parameters to explain and the schema coverage is 100% (empty schema). The description doesn't need to add anything beyond that.

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: to report whether there is a live LinkedIn session, and it specifies the exact method (authenticated GET /voyager/api/me). It distinguishes itself from siblings like linkedin_session_info by emphasizing it measures actual authentication status, not guessed from cookies.

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 effectively communicates when to use this tool—whenever an agent needs to determine authentication status. It explains the methodology and the three possible outcomes, making it obvious how to interpret results. However, it does not explicitly name alternative tools or state when not to use it, so it misses the highest bar for usage guidance.

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

linkedin_cdp_statusA

Is there a browser this server could attach to? A recovery diagnostic.

NOT the normal way to run this server, and not something to reach for first. The daily path is the persistent Chrome profile: sign in once and it holds for as long as LinkedIn honours it. This exists for the day that profile's session dies and an automated sign-in is being refused -- then the operator can run his own Chrome with a DevTools port open and let this server read through that instead.

It needs a Chrome that is ALREADY RUNNING and that was started with --remote-debugging-port. A browser opened from the taskbar has no such port, so "my browser is open" is not enough. Worse, when a Chrome is already running, a second one started with the flag silently hands its arguments to the first and no port opens at all -- so either quit Chrome completely first, or give the new one its own --user-data-dir.

This touches nothing on LinkedIn. It asks the local port what is there and reports the answer, or the exact command to run when nothing answered.

ParametersJSON Schema
NameRequiredDescriptionDefault

No parameters

Output Schema

ParametersJSON Schema
NameRequiredDescription

No output parameters

TDQS

A4.9/5.0
Behavior5/5

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

With no annotations provided, the description carries the full burden of behavioral disclosure. It states the tool touches nothing on LinkedIn, only queries the local port, and reports the answer or the exact command to run when nothing answers. This fully discloses side effects (none) and expected outcomes, making behavior transparent and safe to invoke.

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 the description is relatively long, every sentence earns its place. It front-loads the purpose and the 'not the normal way' nuance, then details prerequisites and pitfalls, and finishes with the safety guarantee and output behavior. The structure is logical and efficient, with no 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?

The description is complete for a no-parameter diagnostic tool with an output schema. It explains the tool's role, when to use it, prerequisites, common failure modes, side effects, and how the result is reported. Nothing an agent needs to decide whether and how to call it 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 schema confirms an empty object. The description does not need to explain parameters, and the baseline for no parameters is 4. It adds nothing beyond that, but nothing is needed—the schema already fully covers the parameter surface.

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: checking for an attachable browser, explicitly framing it as a recovery diagnostic. It distinguishes this from the normal persistent-Chrome-profile path and names the alternative clearly, so an agent can tell this tool apart from its siblings like linkedin_auth_status or linkedin_server_info.

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?

The description explicitly explains when to use this tool (when the profile session dies and automated sign-in is refused) and when not to use it (not the first choice, not the daily path). It also provides operational prerequisites (Chrome already running with --remote-debugging-port) and warns about the second-instance pitfall, giving concrete guidance that leaves no ambiguity.

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

linkedin_followed_companiesA

The company Pages LinkedIn records you as following.

A read, and the exact counterpart of linkedin_saved_jobs: it answers "is this Page already on the list?". This server cannot follow or unfollow anything and ships no tool that could.

THE ONE THING TO READ BEFORE TRUSTING AN ANSWER. LinkedIn draws only the first rows of this list and fetches the rest on scroll; this server opens one page and reads whatever had drawn. So complete is normally false -- measured 2026-08-23, twenty rows under a heading saying 58 Pages -- and a Page missing from pages comes back as UNKNOWN, never as not-followed. Three-valued on purpose: "absent from the rows I was shown" and "you are not following them" are different facts, and reporting the first as the second is how a confirm gate ends up pointing the wrong way.

For a SINGLE employer whose posting you already have, linkedin_job_detail is the better read: it reports company_follow_state off the posting page itself, at no extra page load, and that answer is never partial.

ParametersJSON Schema
NameRequiredDescriptionDefault
limitNohow many rows to return.
companyNoa Page name or numeric Page id to ask about. Leave it out to get the whole rendered list.

Output Schema

ParametersJSON Schema
NameRequiredDescription

No output parameters

TDQS

A4.9/5.0
Behavior5/5

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

With no annotations provided, the description fully carries the burden and does so excellently. It discloses the incomplete list due to scrolling, that 'complete' is normally false, and explains the three-valued semantics (UNKNOWN vs not followed) to avoid misinterpretation. It also notes the server's read-only nature.

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 long, every section earns its place. The critical caveat is front-loaded in a bolded warning, and the structure guides the reader from core purpose to alternatives. Nothing is redundant; it's dense but efficient.

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?

The description covers purpose, limitations, alternatives, and parameter usage thoroughly. Since an output schema exists to explain the return shape, nothing essential is missing. An agent can confidently decide when and how to use this tool.

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?

Both parameters are already well-documented in the schema (100% coverage). The description adds a slight extra: 'Leave it out to get the whole rendered list' for the company parameter, which provides context beyond the schema. Given the high schema coverage, this is adequate but not extensive.

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 resource (company Pages you follow) and the action (list/read). It explicitly identifies itself as the counterpart of linkedin_saved_jobs and answers 'is this Page already on the list?', which precisely distinguishes it from mutation siblings like linkedin_unfollow_company.

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?

It explicitly names linkedin_job_detail as the better alternative for a single employer, explains why (reports company_follow_state directly from the posting page, never partial), and clarifies that this server cannot follow or unfollow anything. This gives clear when-to-use guidance and prevents misuse.

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

linkedin_job_detailA

Read one job posting in full, including the description and the pay.

Every list tool here returns CARDS -- title, company, location, link. This reads the posting behind one of them, which is where the facts that settle a decision actually live: the pay range, LinkedIn's own applicant count, the workplace and employment type, the hiring status, and the description itself. None of those is on any card.

One page load, one posting. There is no sweep and no paging.

A read in both directions: this does not apply to the job, does not save it, and has no way to change anything about the posting. Nobody else on the page is collected either -- LinkedIn draws a hiring team and a "people also viewed" rail beside a job, and neither is read here.

A field the page did not carry comes back null rather than blank. If the page did not render the posting at all the call FAILS instead of returning an empty one: LinkedIn serves the document title before the body, so a title with nothing behind it is never treated as an answer.

ParametersJSON Schema
NameRequiredDescriptionDefault
job_idYesthe numeric job id, or a job url to take it from -- linkedin_search_jobs and linkedin_saved_jobs both return each.

Output Schema

ParametersJSON Schema
NameRequiredDescription

No output parameters

TDQS

A4.6/5.0
Behavior4/5

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

There are no annotations provided (readOnlyHint, destructiveHint are absent), so the description carries the full burden of disclosing behavioral traits. It does this well: it states that this tool does not apply to the job, does not save it, and has no way to change anything about the posting; it also clarifies it does not collect other people on the page. It describes null vs. FAIL behavior for missing fields and unrenderable pages. The only small gap is that it doesn't explicitly mention auth requirements or rate limits, but for a read-only tool with one page load, the disclosure is thorough and covers the key side effects and failure modes.

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 well-structured and front-loaded: the first sentence states the core purpose, then it builds context about what cards lack, confirms the one-page behavior, clarifies non-effects, and explains null/fail semantics. It is a bit longer than strictly necessary (around 150 words) but every paragraph earns its place by addressing a distinct aspect: what this gives, what it does not give, and the specific edge case. The structure is clear and uses paragraphs to separate logical blocks. Slightly verbose in places (repeats 'this does not apply...' phrasing), but overall effective.

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 complexity is simple (one parameter, no nested objects) and there is an output schema (has_output_schema=true) that presumably describes the returned fields, the description covers the critical runtime behaviors an agent needs: what data is available, the distinction from sibling list tools, the null vs. FAIL semantics, and the explicit side-effect-free nature. The description fully equips an agent to decide when to call this tool and what to expect. The only potential missing detail is the exact output fields, but that is covered by the output schema, so the description is complete.

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 schema has a single parameter, job_id, with 100% description coverage (the schema already describes it as the numeric job id or a job URL from two sibling tools). The description reinforces this by mentioning that both linkedin_search_jobs and linkedin_saved_jobs return it, but does not add new meaning beyond what the schema gives. Since coverage is 100% and the description adds a small hint about which tools return the id, a score of 4 is appropriate—slightly above baseline because the description confirms the source of the parameter value, which is useful for an agent.

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 direct, specific statement of what the tool does: 'Read one job posting in full, including the description and the pay.' It clearly identifies the resource (one job posting) and the verb (read). It goes further to distinguish itself from sibling list tools, explaining that this tool retrieves the full posting behind a card, and explicitly lists the facts available only here (pay range, applicant count, etc.). This fully separates it from the list/collection siblings (linkedin_search_jobs, linkedin_saved_jobs) and from action tools (linkedin_save_job, linkedin_unsave_job).

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?

The description is explicit about when to use this tool vs. the sibling list tools: it explains that list tools return only cards with title, company, location, link, while this tool is where the decision-settling facts live. It states there is one page load, no pagination, and explicitly mentions it does not read the 'people also viewed' rail or hiring team. It also provides clear behavioral conditions: the call FAILS if the page did not render the posting, and it treats a title-only page as an error. This gives an agent unambiguous selection criteria.

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

linkedin_login_browserA

Open LinkedIn's sign-in page and wait for you to sign in yourself.

This server never sees, types, stores or transmits a password. It opens a browser window at linkedin.com/login; you type into that window; the persistent Chrome profile keeps the session afterwards, so this is a one-time step until LinkedIn expires it.

The window stays open until the identity endpoint confirms a real session, the window is closed, or wait_seconds runs out. A cookie appearing does not end the wait -- it only causes the endpoint to be asked again. On timeout the result is authenticated false with a reason, never an optimistic success.

ParametersJSON Schema
NameRequiredDescriptionDefault
wait_secondsNohow long to leave the window open for you. Default 300.

Output Schema

ParametersJSON Schema
NameRequiredDescription

No output parameters

TDQS

A4.7/5.0
Behavior5/5

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

With no annotations provided, the description carries the full burden — and it excels. It discloses that the server 'never sees, types, stores or transmits a password,' that the persistent Chrome profile retains the session, and crucially the non-obvious cookie nuance: 'A cookie appearing does not end the wait — it only causes the endpoint to be asked again.' It also states the no-optimistic-success guarantee on timeout. This prevents a common agent failure mode of assuming cookie presence means logged in.

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 purpose in the first sentence, and every subsequent sentence earns its place: the password privacy guarantee, the persistent-profile behavior, the termination conditions, and the cookie/timeout nuance. Nothing is filler; the multi-paragraph length is justified by the density of non-obvious behavioral 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?

Given the tool's interactive-browser complexity and the presence of an output schema, the description covers everything an agent needs: how the interactive flow works, the three termination conditions, the meaning of wait_seconds on timeout, and the return semantics. The one-time-step framing sets expectations correctly. Nothing essential 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?

Schema coverage is 100% and the schema already documents wait_seconds with a default. The description adds value beyond the schema by tying the parameter to observable behavior: 'On timeout the result is authenticated false with a reason, never an optimistic success.' This connects the parameter to the wait-termination and result semantics, which is genuine value added over the schema text.

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+resource+behavior: 'Open LinkedIn's sign-in page and wait for you to sign in yourself.' This clearly distinguishes it from siblings like linkedin_auth_status (checking status) and linkedin_session_info (session info). An agent immediately knows this tool initiates interactive login rather than inspecting or acting on the session.

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 conveys the key usage fact that this is 'a one-time step until LinkedIn expires it,' which tells the agent not to expect repeated calls. However, it does not explicitly route to alternatives such as checking linkedin_auth_status first to determine whether login is even needed. The context is clear but lacks explicit when-not/alternative guidance.

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

linkedin_logoutA

End the local LinkedIn session by erasing this profile's cookie jar.

THE ONE DESTRUCTIVE TOOL IN THIS SERVER, and the most expensive thing it can do to you. The sign-in it throws away took a full day to establish, and there is no automated way to put it back: linkedin_login_browser opens a window and you type into it yourself, exactly as you did the first time.

So confirm is False by default and an unconfirmed call performs NOTHING AT ALL -- no file is opened, no file is even stat-ed, no browser starts, the profile is not read. It hands back a preview naming the exact files a confirmed call would erase, what the sign-in cost, and how you get back in. Read that first; nothing about this is reversible afterwards.

Nothing here reaches LinkedIn. This server stays read-only towards the platform: no request goes out, no session is ended on LinkedIn's side, your account is untouched, and any other browser signed in to it stays signed in. What lapses is purely local -- the cookie jar on this machine.

A profile another process is using is never touched. If the cross-process lock is held, the answer is cleared false naming the holder's PID, because erasing a jar out from under a live Chromium is how a profile gets corrupted -- which costs the same day this tool is asking about.

ParametersJSON Schema
NameRequiredDescriptionDefault
confirmNoFalse -- the default -- previews and performs nothing. True erases the jar.

Output Schema

ParametersJSON Schema
NameRequiredDescription

No output parameters

TDQS

A4.6/5.0
Behavior5/5

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

With no annotations provided, the description carries the full behavioral burden. It thoroughly discloses the destructive nature, the confirm-gated behavior (including that unconfirmed calls do nothing), the local-only scope (no remote impact), and the cross-process lock protection. It is transparent about irreversibility and side effects.

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 lengthy but every paragraph serves a safety-critical purpose: purpose, consequences, confirm behavior, local-only scope, and lock protection. It is front-loaded with the core action, and structure is logical. While it could be trimmed, the verbosity is justified for a destructive tool.

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 destructive complexity and absence of annotations, the description covers all essential aspects: the action, the confirm gate, preview behavior, irreversibility, recovery path, remote non-effect, and cross-process lock. The output schema exists separately, so return value details are not required here.

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

Parameters3/5

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

The input schema already provides a description for 'confirm' that exactly matches the text in the tool description ('False -- the default -- previews and performs nothing. True erases the jar.'). Since schema coverage is 100%, the description adds no additional semantic value beyond reiteration; the baseline of 3 applies.

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: 'End the local LinkedIn session by erasing this profile's cookie jar.' It clearly distinguishes itself as the sole destructive tool among siblings, making selection unambiguous.

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?

It explicitly warns that this is the one destructive tool and explains the cost of recovery, pointing to linkedin_login_browser as the alternative for re-establishing a session. The description implies when to use it (when you intend to end session) and strongly cautions against casual use, providing clear usage context.

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

linkedin_my_applicationsA

List the jobs you have applied to on LinkedIn, with their status.

Reads the Applied tab of your own job tracker (the page My Items > Applied became). Each row carries title, company, location, the status LinkedIn shows (applied, application viewed, resume downloaded, no longer accepting applications) and how long ago, plus the job id and link.

Status is whatever LinkedIn displays; this server does not infer, score or chase anything, and it cannot see applications you made anywhere else.

An empty result says so explicitly and carries LinkedIn's own count for the tab, so "you have applied to nothing" and "this could not be read" are never the same answer. If the two disagree -- no rows, but a non-zero count -- you get an error rather than an empty list.

ParametersJSON Schema
NameRequiredDescriptionDefault
limitNomaximum rows to return (default 25, max 100).

Output Schema

ParametersJSON Schema
NameRequiredDescription

No output parameters

TDQS

A4.4/5.0
Behavior5/5

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

No annotations are provided, so the description carries the full burden. It discloses that status is whatever LinkedIn displays, that the server does not infer or chase, and explicitly explains edge cases: empty results carry LinkedIn's count, and a mismatch between rows and count yields an error. This is exceptionally transparent about behavior.

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 multi-sentence and a bit longer than strictly needed, but every sentence adds value: scope, row contents, status behavior, and edge cases. It could be tightened slightly, but it is well-structured and front-loads the primary action.

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?

The description explains row contents (title, company, location, status, how long ago, job id, link), clarifies that statuses mirror LinkedIn's display, and covers empty-result and error disambiguation. Given the simple schema and the presence of an output schema, this is complete enough for an agent to call correctly.

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

Parameters3/5

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

Schema description coverage is 100% for the only parameter (limit), and the schema already documents its behavior fully. The description adds no further parameter details, so the baseline score of 3 is appropriate per the scoring rules.

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 exactly what the tool does: 'List the jobs you have applied to on LinkedIn, with their status.' It names a specific resource (own job tracker's Applied tab) and the action (list), and clarifies it reads only your own applications. This clearly distinguishes it from siblings like saved_jobs or search_jobs.

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?

While it doesn't explicitly name alternative tools or say when not to use it, the description makes the scope obvious: it reads only the Applied tab and cannot see applications made elsewhere. This is sufficient guidance for an agent to know when to invoke it versus other job-related tools.

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

linkedin_my_profileA

Read your own LinkedIn profile as LinkedIn currently stores it.

Returns name, headline, location, the About text, and which sections were on the page when it was read.

On completeness: LinkedIn's own profile-strength meter is not exposed here, so this server does not report one. What it reports is derived and labelled as such.

One honest limitation, stated because its absence would otherwise read as data: LinkedIn now defers Experience, Education and Skills until the page is SCROLLED, and this server does not scroll. Those sections are therefore usually absent from the render, and absent means UNKNOWN here, never zero. sections_not_rendered names them, and details_urls gives you the page for each one if you want to look yourself.

ParametersJSON Schema
NameRequiredDescriptionDefault
include_skillsNoalso load the full skills page, which is where a real skills list can be read. That is a second page load, reported as pages_loaded: 2. Pass false to stay at one.

Output Schema

ParametersJSON Schema
NameRequiredDescription

No output parameters

TDQS

A4.7/5.0
Behavior5/5

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

With no annotations provided, the description carries full responsibility for behavioral disclosure. It is exceptionally transparent: it explains that LinkedIn defers certain sections until scrolling and that this server does not scroll, so those sections are usually absent and reported as UNKNOWN, not zero. It also admits the profile-strength meter is not exposed and that derived data is labelled as such.

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 longer than average but every sentence adds information: it states the purpose, enumerates outputs, discloses limitations, and clarifies how to interpret absence. It is well-paragraphed and front-loads the core purpose before diving into nuance. No filler exists.

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 read-only profile tool with an output schema, the description covers all necessary aspects: what is returned, the caveat about missing sections, how to interpret missing data, and a hint about the details_urls for further investigation. The 'include_skills' parameter behavior is also disclosed through the schema. Nothing an agent needs to call and interpret the tool correctly is omitted.

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?

There is a single parameter, include_skills, and its schema description is already very informative, explaining the effect (additional page load, pages_loaded: 2) and the trade-off. The description itself does not elaborate on the parameter, but because the schema coverage is 100% and the schema text is detailed, the parameter semantics are clear. A score of 4 reflects the extra value of the schema description beyond a bare type.

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 uses a specific verb and resource ('Read your own LinkedIn profile') and immediately lists the concrete output fields (name, headline, location, About text, sections). It clearly distinguishes itself from siblings by focusing on the user's own profile and explicitly notes what is not included (profile-strength meter).

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 the tool is appropriate (reading your own profile) and provides guidance on handling missing sections by pointing to details_urls. It does not explicitly contrast with sibling tools, but the tool name and the content give enough context for correct selection. The explanation of the 'include_skills' parameter also adds usage context.

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

linkedin_notificationsA

List your LinkedIn notifications as they appear on the notifications page.

====================== SIDE EFFECT -- READ FIRST ====================== THIS TOOL CHANGES SOMETHING ON LINKEDIN. Loading the notifications page CLEARS YOUR UNREAD BADGE -- every notification LinkedIn was still counting as unread stops being counted, exactly as if you had opened the page yourself. MEASURED, not theorised: one call on 2026-08-21 took the badge from 1 to 0, and it does not come back.

It cannot be avoided. LinkedIn marks the list seen on the server when the page is served, so there is no read of this surface that leaves the badge alone: no click, no scroll and no per-item open is involved, and there is no mark-as-read call anywhere in this package. The only way not to clear the badge is not to call this tool.

It is the ONE server-side change any tool here causes. Everything else in this package leaves LinkedIn exactly as it found it.

Partial compensation, since the badge is going either way: each row carries "unread": true/false as LinkedIn had it AT THE MOMENT OF READING -- which is the fact the page load is about to destroy. Read it here or lose it.

Rows carry the notification text, how long ago it arrived, whether it was unread, and the link LinkedIn attaches. Screen-reader-only text ("Unread notification.", "Status is reachable") is stripped, so the body is what you would read on screen.

ParametersJSON Schema
NameRequiredDescriptionDefault
limitNomaximum notifications to return (default 20, max 50).

Output Schema

ParametersJSON Schema
NameRequiredDescription

No output parameters

TDQS

A4.4/5.0
Behavior5/5

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

Annotations are absent, so the description carries the full burden — and it delivers exceptionally. It discloses the badge-clearing side effect with a dated measured example (badge 1→0), states it is unavoidable, confirms this is the only server-side change in the package, and describes the partial compensation (unread flags captured at read time) plus input stripping behavior. This is model behavioral disclosure.

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 long (~300 words), but the length is earned: the side-effect disclosure requires specific, verifiable detail. The purpose is front-loaded before the warning block, and the block formatting makes the critical information visually distinct. Minor deduction for verbosity in the compensation paragraph, which could be tightened.

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 single-parameter list tool with full schema coverage, an output schema, and a thoroughly disclosed side-effect profile, nothing an agent needs to call it correctly is missing. Return format is described in prose (notification text, time, unread flag, link), complementing the output schema.

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

Parameters3/5

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

Schema description coverage is 100% — the single 'limit' parameter (default 20, max 50) is fully documented in the schema. The description adds no parameter-specific meaning beyond the schema, which the rubric sets as the baseline 3. Nothing beyond the baseline, but nothing missing either.

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 first sentence states a specific verb (List) and resource (your LinkedIn notifications) with a clear frame of reference ('as they appear on the notifications page'). It is unmistakably distinct from the sibling tools, all of which cover auth, sessions, jobs, or profile surfaces — no other tool claims the notifications surface.

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 does not explicitly name an alternative, but none of the siblings overlap with notifications, so exclusion guidance is unnecessary. It instead provides strong context on when NOT to call: the side-effect block makes the cost explicit ('The only way not to clear the badge is not to call this tool'), which functions as clear usage guidance. Slight deduction for not naming an alternative, though no natural one exists.

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

linkedin_saved_jobsA

List the jobs you have bookmarked on LinkedIn.

Reads the Saved tab of your own job tracker: title, company, location, when it was posted where LinkedIn shows it, and the job link.

Read-only in both directions -- this lists what you saved and has no way to add to or remove from the list.

An empty result says so explicitly and carries LinkedIn's own count for the tab, so an empty list can never be mistaken for a read that failed.

The tracker also holds In Progress, Interview and Archived tabs. They are not exposed as tools: this reads the two lists it names and nothing else.

ParametersJSON Schema
NameRequiredDescriptionDefault
limitNomaximum rows to return (default 25, max 100).

Output Schema

ParametersJSON Schema
NameRequiredDescription

No output parameters

TDQS

A4.3/5.0
Behavior5/5

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

With no annotations at all, the description carries the full disclosure burden and handles it well. It explicitly declares read-only behavior in both directions (covering what destructiveHint would normally signal) and gives a strong edge-case guarantee: an empty result is stated explicitly and carries LinkedIn's own count, so an empty list can never be misread as a failed call. This is the kind of behavioral detail agents genuinely need.

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

Conciseness3/5

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

The opening line is profitably front-loaded, but the body is somewhat verbose for a single-parameter list tool and contains an internal inconsistency: the last sentence says 'this reads the two lists it names' when only one list (the Saved tab) is actually named. Phrases like 'when it was posted where LinkedIn shows it' are clunky. The redundancy around read-only scope could be tightened.

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 simplicity (one optional parameter, no required inputs, an output schema present), the description is more than sufficient. It covers the safety profile, the exact scope, and the empty-result edge case, leaving no gap an agent needs to fill before calling it. The 'two lists' wording is a minor editorial slip, not a completeness failure.

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

Parameters3/5

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

The schema covers 100% of the single parameter (limit) with a complete description ('maximum rows to return (default 25, max 100)'). The tool description adds no parameter-level detail, which is acceptable since the schema already documents the limit fully and there are no other parameters to explain. Baseline 3 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 first line uses a specific verb and resource ('List the jobs you have bookmarked on LinkedIn') that clearly identifies this as the read counterpart to the save/unsave siblings. It names exactly what is returned (title, company, location, post date, link) and scopes itself to the Saved tab, distinguishing it from search and job-detail tools.

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 states the tool reads only the Saved tab and explicitly notes that In Progress, Interview and Archived tabs are 'not exposed as tools.' The read-only framing ('no way to add to or remove from the list') implicitly routes agents away from linkedin_save_job/linkedin_unsave_job, though it does not name those alternatives directly or give an explicit when-to-use statement.

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

linkedin_save_jobA

Bookmark one job posting on LinkedIn. Two steps, and the first is free.

THIS TOOL CHANGES SOMETHING ON LINKEDIN, which no other tool in this server does. It is the reason linkedin_server_info no longer reports read_only: true.

CALL IT WITHOUT confirm_token FIRST. Nothing is done: the posting and your own saved list are read live, and you get back a block naming the job by title and employer, saying which way the toggle would move, where each fact was read from, and how the action can be undone. Read it, then call again with the confirm_token it hands you.

The token works ONCE, only for this posting, only for this verb, and it expires in two minutes -- so a scheduled or unattended caller can never hold a live one. That is the intended consequence and not a side effect.

After the click the result is confirmed from a DIFFERENT surface: your saved list, with LinkedIn's own per-tab count, rather than from the button that was just pressed. performed comes back true, false, or "unknown"; on "unknown" do not retry, because a retry on a toggle that did land performs the opposite action -- look at your saved jobs instead.

ParametersJSON Schema
NameRequiredDescriptionDefault
job_idYesthe numeric LinkedIn job id, as it appears in /jobs/view/<id>.
confirm_tokenNoleave empty to preview. Pass the token from that preview to actually save.

Output Schema

ParametersJSON Schema
NameRequiredDescription

No output parameters

TDQS

A4.8/5.0
Behavior5/5

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

With no annotations provided, the description carries the full burden of behavioral disclosure, and it excels: it states the tool mutates LinkedIn state, explains the preview/confirm two-step mechanism, describes the token's single-use, per-post, per-verb, two-minute expiry, and discloses that confirmation is read from a different surface (the saved list) rather than the button. It also explains the 'performed' result values and the risk of opposite actions on retry. This is exhaustive and transparent.

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 longer than typical, but it is well-structured and front-loaded: the opening sentence captures the core action and two-step nature. Subsequent paragraphs each add necessary detail (token lifecycle, confirmation source, retry warning) without redundancy. Every sentence serves a purpose; while verbose, it respects the complexity of the operation.

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 state-changing complexity and two-step flow, the description is exceptionally complete. It covers the preview, confirmation, token expiry, result semantics, retry avoidance, and even a fallback suggestion ('look at your saved jobs instead'). An output schema exists, but the description still clarifies the meaning of 'performed' and the confirmation pathway, leaving no critical operational detail 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?

Schema coverage is 100%, so the schema already documents both parameters. The description adds meaningful context for confirm_token—its lifecycle, the preview/confirm pattern, and that an empty value means preview—which goes beyond the schema. For job_id, it mostly repeats the schema's description but confirms it as a numeric ID. The added semantic depth for confirm_token raises the score above the baseline of 3.

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 explicitly states the verb 'Bookmark' and the resource 'job posting on LinkedIn', and immediately distinguishes this tool from all read-only siblings by noting it is the only one that changes something on LinkedIn. It also implicitly contrasts with the sibling 'linkedin_unsave_job' by describing the save action. 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 Guidelines5/5

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

The description gives a precise two-step procedure: call without confirm_token to preview, then call again with the token to save. It clearly states when to use the tool (when you want to save a job) and provides explicit warnings about token expiration, single-use, and when not to retry ('on "unknown" do not retry'). It also mentions the intended consequence of preventing unattended callers, further clarifying appropriate usage.

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

linkedin_search_jobsA

Search LinkedIn jobs with filters, returning one page of results.

Runs the search LinkedIn's own jobs page runs and reads the rendered results: title, company, location, job id and link.

One page load per call, no scrolling and no auto-paging -- LinkedIn puts roughly 25 results on a page, so ask for the next page deliberately with start=25, start=50 and so on. capped in the result tells you the limit trimmed the rows, and page_had tells you how many the page actually held.

Note that LinkedIn records searches in your own recent-search history, exactly as it would if you typed the query on the site. That is the only trace a search leaves, and it is on your account, not anyone else's.

ParametersJSON Schema
NameRequiredDescriptionDefault
limitNomaximum rows to return (default 25, max 50).
startNoresult offset for manual paging (0, 25, 50 ...).
remoteNoany | on_site | remote | hybrid.any
sort_byNorelevance | date.relevance
keywordsYeswhat to search for, e.g. "senior node.js engineer".
locationNocity, region or country. Empty means LinkedIn's default.
date_postedNoany | past_24h | past_week | past_month.any
experience_levelNocomma-separated from internship, entry, associate, mid_senior, director, executive. Empty means no filter.

Output Schema

ParametersJSON Schema
NameRequiredDescription

No output parameters

TDQS

A4.4/5.0
Behavior5/5

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

With no annotations provided, the description carries the full disclosure burden. It exceeds expectations by detailing that each call is a single page load with no scrolling or auto-paging, that results are roughly 25 per page, and that capped/page_had indicate row trimming. Most notably, it discloses a non-obvious side effect: searches are recorded in the user's own LinkedIn recent-search history, leaving a trace on the account. This level of transparency about side effects and pagination mechanics 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.

Conciseness4/5

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

The description is structured into three focused paragraphs: purpose, pagination mechanics, and privacy note. It is front-loaded with the core purpose in the first sentence. Every sentence contributes either operational details (single page, start offsets) or behavioral transparency (capped, page_had, search history). It is concise relative to the amount of critical information it conveys, though slightly longer than the bare minimum. The structure makes the key usage guidance easy to find.

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 search tool with 8 parameters and an output schema, the description covers all necessary aspects: what the tool does, how it works, how to page for more results, what the returned fields include (title, company, location, job id, link), and the side effect on search history. The presence of an output schema means the return format needn't be detailed, and the description still names the key fields. Nothing essential is missing for an agent to call this correctly.

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

Parameters3/5

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

Schema description coverage is 100%, so the baseline is 3. The description does not add significant meaning beyond the schema: it mentions start for paging and limit indirectly through the pagination discussion, but the schema already documents defaults, enums, and descriptions for every parameter (keywords, start, limit, remote, etc.). The description's pagination explanation adds marginal value over the schema's 'result offset for manual paging' line, but not enough to raise the score above baseline.

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 crisp statement of function: 'Search LinkedIn jobs with filters, returning one page of results.' It then specifics the returned fields (title, company, location, job id, link) and indicates the search runs on LinkedIn's own jobs page. This clearly distinguishes it from sibling tools like linkedin_job_detail (which likely retrieves a single job) and linkedin_saved_jobs (which lists saved jobs). The purpose is unambiguous and specific.

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 provides explicit operational guidance: it explains the single-page behavior and instructs the agent to page deliberately using start=25, start=50, etc. It also explains the meaning of capped and page_had fields for interpreting results. However, it does not explicitly say 'use this instead of X' for any sibling tool, though the distinct search function is implied. The guidance is clear on how to use the tool for repeated paginated searches, which is the core usage pattern.

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

linkedin_server_infoA

Describe this server: what it can do, what it deliberately cannot.

Useful for confirming the read-only boundary and the rate settings without reading the source.

version and build.code.commit are two different facts and both are reported. version is a HAND-MAINTAINED label: it says what this server calls itself, and it keeps saying it whether or not anybody remembered to bump it. build.code.commit is MEASURED -- it is the commit this process was imported from, read once at import and frozen.

WHAT TO DO WITH IT. A fix committed to disk changes nothing for a server that is already running. To tell "the fix is not loaded" from "the fix is wrong", compare build.code.commit against git rev-parse HEAD in the checkout::

git -C <this checkout> rev-parse --short=12 HEAD

They MATCH -> the running process holds that commit, so a bug you can still reproduce is a real bug. They DIFFER -> the process is STALE and no further committing will change its behaviour until the MCP client restarts it. build.code.dirty says whether the working tree had uncommitted changes when this process started, so a matching commit with dirty: true means the commit is necessary but not sufficient to describe what is loaded. build.process.started_at dates the answer.

ParametersJSON Schema
NameRequiredDescriptionDefault

No parameters

Output Schema

ParametersJSON Schema
NameRequiredDescription

No output parameters

TDQS

A4.6/5.0
Behavior5/5

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

With no annotations, the description fully carries the burden. It discloses that version is a hand-maintained label while build.code.commit is measured and frozen at import, distinguishes between stale and current processes, explains how to compare against git rev-parse HEAD, and covers the dirty flag and started_at field. This goes well beyond what annotations would provide and gives the agent complete behavioral expectations.

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 front-loaded with the main purpose and then dives into detailed interpretation of version/build fields. It is well-structured with a clear flow, but it is somewhat verbose, especially the code block and extended explanation of staleness. Every sentence adds value, so it is not padded, but it could be tightened without losing essential meaning.

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?

The description is extraordinarily complete for a no-parameter tool. It explains the meaning of each output field (version, build.code.commit, dirty, started_at), how to interpret them together, and even provides a practical procedure (git rev-parse HEAD) for diagnosing stale servers. Nothing an agent needs to call or interpret this tool 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, so the schema is trivially complete (100% coverage). Per the rubric, a baseline of 4 applies, and the description adds no parameter detail (none is needed). No deduction is warranted.

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 "Describe this server: what it can do, what it deliberately cannot," a specific verb/resource pair that clearly distinguishes this tool from siblings like linkedin_auth_status or linkedin_session_info. It states the exact scope (server capabilities, read-only boundary, rate settings) and the primary output (version and build information). No ambiguity about what this 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 explicitly states when to use it ("Useful for confirming the read-only boundary and the rate settings without reading the source"). It stops short of naming alternatives or explicit when-not-to-use conditions, but the context of the sibling list makes the intended use clear. A 5 would require an explicit comparison to alternatives, which is missing.

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

linkedin_session_infoA

Report whether the session is live and how long it has left.

This is the question that comes up after a week away: is the sign-in still good, and when does it lapse? The verdict is the same measured one linkedin_auth_status gives -- an authenticated call to the identity endpoint, never a cookie's presence -- and alongside it comes the expiry date read from the browser profile's own cookie jar.

The sign-in lives in an on-disk Chrome profile rather than in this process, so it survives this server restarting and the machine rebooting. What ends it is LinkedIn expiring it, a sign-out, or the profile directory going away.

When no browser can be started at all -- Chromium missing, another process holding the profile -- this tool does not die with it. It falls back to reading the expiry dates out of that profile's cookie jar on disk, which is precisely the moment you most want to know whether the login survived. Then 'authenticated' is null and the live_check block says why in plain words: a cookie in the jar is not a session, and reporting one as the other is a lie this server refuses to tell. Two labelled fields, never one blurred one.

What comes back, block by block, so a caller is never guessing:

  • credential -- li_at, the one cookie that authenticates here. Its name, whether it is there, its expiry, and expiry_source naming which route produced that date: the live browser's jar, or the on-disk jar read with no browser.

  • supporting -- JSESSIONID, role csrf. Not a second credential: it cannot sign anything in, it only governs whether the identity call can be made at all. It dies with the browser and a fresh one arrives on the next page load, so it having lapsed means nothing on its own.

  • renewal -- silent_renew_available is false here, and why says what the four servers in this family were ruled on: there is one credential layer, so a linkedin_reauth would be linkedin_login_browser wearing a different name and it is deliberately not shipped. It also carries session_lapses_at / _in_days: the date past which no silent renew can help and you sign in by hand. THAT is the number to compare against a sibling server, not credential.expires_at -- a server that re-mints its own credential shows hours there while its session holds for months. On LinkedIn the two coincide, because nothing here can carry the session past the cookie, and session_lapses_source says so. uses_browser is null rather than false -- there is no renewal here to characterise, and a false would claim one exists and happens to need no browser -- while mechanism spells out what recovery actually costs: a real window and your own hands, never a background refresh.

  • durability -- where the sign-in is kept and what it survives.

Cookie values are never returned. Only the name, whether it is there, and when it lapses. When it has lapsed every read tool says so with a reason rather than handing back nothing, and linkedin_login_browser is the way back.

(Everything below this point is dropped from the description a caller sees: FastMCP cuts a docstring at Args: and renders the rest into the argument schema. Prose that has to reach a caller goes ABOVE it.)

ParametersJSON Schema
NameRequiredDescriptionDefault
verify_liveNoput the question to the identity endpoint for a real verdict, which requires a working browser. Pass False for the free, browserless answer -- jar facts only, 'authenticated' null. Default True.

Output Schema

ParametersJSON Schema
NameRequiredDescription

No output parameters

TDQS

A4.6/5.0
Behavior5/5

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

With no annotations, the description carries the full burden and delivers heavily: it discloses the measurement method (identity endpoint, never cookie presence), the persistence of the session in an on-disk Chrome profile, the fallback to reading the jar without a browser and the resulting null 'authenticated', the fact that cookie values are never returned, and the semantic of renewal (silent_renew_available false, costs manual sign-in). This is extensive behavioral disclosure beyond the basics.

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

Conciseness2/5

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

The description is extremely verbose. It includes narrative prose ('This is the question that comes up after a week away...'), extensive block-by-block explanations, and a meta-note about FastMCP cutting the docstring. While structured with headings, the length is excessive for an AI caller; many sentences could be trimmed without losing essential information. The front-loaded purpose is clear, but the overall bulk detracts from usability.

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 no annotations and a fairly complex behavior (fallback, multiple output fields, nuanced semantics), the description is extremely thorough. It explains each output block, the meaning of null, the reasons behind renewal limitations, and the durability of the session. An agent would have all necessary context to call it correctly, even without seeing the output schema (which exists but is not needed to understand the tool).

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 schema already covers the only parameter (verify_live) with a clear description, so the baseline is 3. The description adds value by explaining the trade-offs in context: it details what True vs False means for the live_check block, the fallback when no browser is available, and the null semantics. This enriches the parameter's meaning beyond the schema's concise text.

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 opening sentence clearly states the tool's purpose: 'Report whether the session is live and how long it has left.' It goes further to differentiate from linkedin_auth_status by specifying that it uses the same identity-endpoint verdict but also includes the expiry date from the cookie jar, and it names linkedin_login_browser as the recovery path. This makes the resource and scope unambiguous.

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?

The description explicitly references alternatives and when to choose them: it says the verdict is the same as linkedin_auth_status, points out that linkedin_login_browser is the way back after lapse, and explains the fallback behavior when no browser can be started. It also tells when to set verify_live True vs False. This gives clear decision rules for an agent.

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

linkedin_unfollow_companyA

Stop following one company Page. Two steps, and the first is free.

Same two-step shape and the same five gates as linkedin_save_job. What differs is worth reading before you use it, because two of the differences change what an answer from this tool means.

IT IS ADDRESSED BY THE NUMERIC COMPANY ID, NOT BY NAME. Call linkedin_followed_companies first: it prints the id beside each Page. A name is refused outright -- names collide, they change, and they belong to somebody else -- and the click is anchored to the row carrying the id, so the thing you name and the thing that gets pressed are the same row by construction. The preview still prints the NAME, because an id is not something a person can check.

THE LIST IS NEVER COMPLETE, AND THAT IS THE IMPORTANT ONE. LinkedIn renders about twenty rows of however many you follow, and offers no way to page through the rest. So a Page that is not in the rendered rows comes back "unknown" rather than "not followed", and the preview refuses rather than guessing. If the company you want is not reachable, this tool will say so instead of doing nothing quietly.

Confirmation after the click is read by RELOADING the same list -- there is no second surface that lists followed Pages -- and the verdict rests on LinkedIn's own stated total dropping by one, not on the row having vanished. On a partial list an absent row is not evidence.

THE PAIR IS ASYMMETRIC ON PURPOSE: this server can stop a follow and cannot start one. linkedin_follow_company is specced and is not performed.

ParametersJSON Schema
NameRequiredDescriptionDefault
company_idYesthe numeric LinkedIn company id, as printed by ``linkedin_followed_companies``.
confirm_tokenNoleave empty to preview. Pass the token from that preview to actually unfollow.

Output Schema

ParametersJSON Schema
NameRequiredDescription

No output parameters

TDQS

A4.9/5.0
Behavior5/5

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

With no annotations provided, the description carries the full burden and excels at it. It discloses the two-step confirmation flow, the lack of pagination in the followed-companies list and its consequences, the confirmation method (reloading the list and checking the total count drop), and the 'unknown' vs 'not followed' distinction. It even explains why a partial list makes absent rows unreliable evidence. This is exemplary behavioral disclosure.

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 long, the description is tightly structured and every sentence earns its place. The key warnings are front-loaded and emphasized with caps, and the paragraphs each address a distinct behavioral aspect. It is verbose only where necessary; the complexity of the tool justifies the length, and the structure makes it 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?

For a tool with an output schema and non-trivial behavioral caveats, this description is complete. It covers prerequisites (get the ID), limitations (list is partial), verification (count drop), and side-effect asymmetry. Nothing an agent needs to call it correctly or interpret results 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?

Schema coverage is 100%, so the parameters are already documented. The description adds contextual meaning: company_id is clarified as the numeric id printed by the followed list (not the name), and confirm_token is explained as the preview vs final confirmation flow, with an empty value meaning preview. This goes beyond the schema's basic 'leave empty to preview' by tying it into the overall two-step process, justifying a 4 over the baseline 3.

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 precise verb+resource ('Stop following one company Page') and immediately differentiates from siblings by referencing the shared two-step shape and five gates, and by naming the asymmetry with linkedin_follow_company. It leaves no ambiguity about what the tool does and how it differs from related tools.

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?

The description gives explicit when-to-use guidance: call linkedin_followed_companies first to obtain the numeric ID, explains that names are refused, and details the list incompleteness that makes a 'unknown' verdict possible. It also states the asymmetry (can unfollow but not follow) and directs to the unimplemented follow tool for the inverse, leaving no room for misuse.

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

linkedin_unsave_jobA

Remove one job posting from your saved list. Built, gated, and refusing.

Same two-step shape as linkedin_save_job and the same gates, with ONE honest difference that this docstring will not bury: THIS TOOL CANNOT PERFORM ANYTHING TODAY, and it is not because the code is missing.

LinkedIn labels the save control by its accessible name, and the name it wears when a posting IS saved has never been observed on this account -- there has been nothing saved on it to observe. Every capture this repo holds shows the unsaved state. So the selector an unsave would click is unknown, and this server will not guess one: "Saved" and "Unsave the job" are both plausible spellings and it has seen neither.

THE FIX IS ONE MEASURED LINE, and the first supervised save produces it -- linkedin_save_job reads back the label the control changes into and reports it. Until that label is written down, this refuses with that explanation rather than clicking something it hopes is the right button.

A preview may also be unrenderable for a second and unrelated reason: an unsave is only valid on a posting that is currently saved, so with an empty saved list there is nothing to preview it against. The preview says so.

ParametersJSON Schema
NameRequiredDescriptionDefault
job_idYesthe numeric LinkedIn job id, as it appears in /jobs/view/<id>.
confirm_tokenNoleave empty to preview. A token is accepted but the action behind it will refuse until its anchor has been measured.

Output Schema

ParametersJSON Schema
NameRequiredDescription

No output parameters

TDQS

A3.8/5.0
Behavior5/5

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

With no annotations supplied, the description carries the full burden of behavioral disclosure. It is exceptionally transparent: it confesses 'THIS TOOL CANNOT PERFORM ANYTHING TODAY,' explains the technical reason (unknown accessible name), details the preview failure condition, and states it will refuse rather than guess. This level of honest disclosure 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.

Conciseness2/5

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

The description is a multi-paragraph essay covering the tool's broken state, future fix, and preview behavior. While informative, it is verbose and could be condensed into a few sentences. An MCP description should be concise; this one isn't.

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 has an output schema and no annotations, the description must convey behavioral context. It thoroughly covers why the tool refuses, how preview behaves, and the condition for it to work, leaving no ambiguity about what will happen when called. It is complete for an agent to handle correctly.

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

Parameters3/5

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

Schema coverage is 100% – both parameters (job_id and confirm_token) are fully described in the input schema, including the preview vs. token behavior. The description adds no additional parameter-level information, which is acceptable given the schema already covers semantics. Baseline 3 applies.

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 opens with 'Remove one job posting from your saved list,' which is a clear verb+resource statement. It implicitly differentiates from linkedin_save_job by being the inverse operation, though it doesn't explicitly compare with siblings. The core purpose is unambiguous despite the lengthy caveats about the tool being non-functional.

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 implies usage when a user wants to unsave a job and explicitly states the tool cannot currently perform the action and will refuse until a label is measured. It also mentions the preview limitation based on having a saved job. However, it doesn't explicitly state when not to use it or mention alternatives, though the refusal condition is clear.

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

linkedin_who_viewed_meA

List the people who viewed your profile, most recent first.

The highest-intent signal in a job search: someone who opened your profile has already spent attention on you. Where the account has Premium Career, this list reaches back 365 days rather than the free tier's five viewers.

Rows carry name, headline, when the view happened, and a profile link.

Viewers browsing with limited visibility appear exactly as LinkedIn shows them to you and no more: "Someone at Acme", "Recruiter at Acme", with a date and no link, flagged "anonymous": true. They are the majority of a typical list and often the most useful part of it -- a recruiter's view is a recruiter's view whether or not it comes with a name. This server makes no attempt to work out who they are, and there is no code here that could: nothing is fetched about any viewer, and no viewer's profile is ever opened. What you get is the row LinkedIn already put on your screen.

Reads the Premium analytics page. The older /me/profile-views/ address now redirects to that same page, so the second attempt is a re-load for a page that had not finished rendering rather than a different surface; it still reports pages_loaded: 2 when it happens.

ParametersJSON Schema
NameRequiredDescriptionDefault
limitNomaximum rows to return (default 25, max 100).

Output Schema

ParametersJSON Schema
NameRequiredDescription

No output parameters

TDQS

A4.4/5.0
Behavior5/5

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

With no annotations provided, the description takes full responsibility for behavior. It discloses that some viewers appear as anonymous with limited info, states the server makes no attempt to identify them and fetches nothing about viewers, mentions the data source (Premium analytics page), and explains the redirect behavior and resulting pages_loaded count. This is exceptionally transparent.

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 longer than average, but it is front-loaded with the core purpose and organized into informative paragraphs. Each segment adds value: the high-intent context, anonymous viewer behavior, technical data source, and the redirect edge case. It is structured logically and avoids redundancy, though it could be tightened without losing clarity.

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 simplicity (one parameter, no nested objects) and the presence of an output schema, the description still covers all essential behavioral context: the kind of rows returned (name, headline, when, link), anonymous flagging, the data source, and the redirect quirk. It even explains what the server does not do. This is complete for an agent to use correctly.

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

Parameters3/5

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

Schema description coverage is 100%, so the baseline is 3. The sole parameter 'limit' is already fully described in the schema with default and max values; the tool description adds no additional semantic meaning or usage nuance beyond that. Thus it meets the baseline without needing extra explanation.

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 'List the people who viewed your profile, most recent first' — a precise verb and resource. It clearly distinguishes from sibling tools like linkedin_search_jobs or linkedin_my_applications by specifying the unique action of listing profile viewers, and provides additional context about the 'highest-intent signal in a job search.'

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 provides strong contextual guidance on when this tool is valuable ('the highest-intent signal in a job search' and notes that anonymous viewers are often the most useful part), implying usage in recruiter outreach scenarios. However, it does not explicitly exclude or compare against sibling tools, so it falls short of a 5 but is above a 3 due to the clear situational context.

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. 17 tool updatesv0.1.0
    • First observedlinkedin_auth_status
    • First observedlinkedin_cdp_status
    • First observedlinkedin_followed_companies
    • First observedlinkedin_job_detail
    • First observedlinkedin_login_browser
    • First observedlinkedin_logout
    • First observedlinkedin_my_applications
    • First observedlinkedin_my_profile
    • First observedlinkedin_notifications
    • First observedlinkedin_save_job
    • First observedlinkedin_saved_jobs
    • First observedlinkedin_search_jobs
    • First observedlinkedin_server_info
    • First observedlinkedin_session_info
    • First observedlinkedin_unfollow_company
    • First observedlinkedin_unsave_job
    • First observedlinkedin_who_viewed_me

TDQS

A4.2/5.0

Scored across 17 tools

Disambiguation5/5

Each tool targets a distinct action or query: auth status, session details, login, logout, CDP diagnostics, profile viewers, applications, saved jobs, job search, job detail, followed companies, own profile, notifications, save job, unsave job, unfollow company, server info. Even related tools like auth_status and session_info are clearly differentiated by scope.

Naming Consistency4/5

All tools begin with 'linkedin_' and use snake_case, but the pattern mixes noun phrases (linkedin_my_profile, linkedin_notifications) with verb-noun (linkedin_search_jobs, linkedin_save_job) and state queries (linkedin_auth_status). While consistent in prefix, the verb usage is not uniform, though still readable.

Tool Count4/5

17 tools is slightly above the ideal 3-15 range but each serves a distinct purpose, from session management to job and company interactions. The count is justified by the breadth of LinkedIn functionality covered, though a few are diagnostic or advisory.

Completeness3/5

The server covers the main job-search workflow (search, detail, saved, applications) and profile viewing, but has notable gaps: no way to follow a company (only unfollow), and the unsave_job tool is explicitly non-functional. Additionally, there is no tool for applying to jobs or editing profile, leaving some expected actions absent.

Maintenance

ActivityMaintained
ResponsivenessNo issues

Related MCP Connectors

Related MCP Servers

  • A
    license
    B
    quality
    C
    maintenance
    Enables AI agents to manage LinkedIn profiles, posts, connections, skills, education, and certifications through the LinkedIn API.
    18
    176 npm
    64
    MIT
  • A
    license
    Not graded
    quality
    D
    maintenance
    Enables AI assistants to connect to LinkedIn, accessing profiles and companies, searching for jobs and people, managing saved jobs, updating job-search profile settings, and inspecting analytics.
    1
    Apache 2.0
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables natural language interaction with LinkedIn, including profile retrieval, job searching, messaging, and post engagement.
    3
    Apache 2.0