linkedin-mcp
This server automates your own LinkedIn account through a real logged-in browser session, with read tools for profiles, feed, jobs, invitations, and connections, and write tools (post, connect, message, Easy Apply) that require an explicit preview-then-confirm handshake before anything is sent.
Login/session:
linkedin_loginopens a visible browser for you to sign in manually;linkedin_session_statuschecks the saved session.Post: publish text posts to your feed (
linkedin_create_post), with optionalpublic/connectionsvisibility.Connect: send connection invitations with optional notes (
linkedin_send_connection_request).Message: send direct messages to 1st-degree connections or existing threads (
linkedin_send_message).Scrape profile: read structured profile data — name, headline, about, experience, education, skills (
linkedin_scrape_profile).Scrape feed: read recent feed posts with author, text, engagement counts (
linkedin_scrape_feed).Search jobs: find job listings with filters like Easy Apply, date posted, experience level, remote (
linkedin_search_jobs).Apply to jobs: submit LinkedIn Easy Apply applications, optionally answering form questions (
linkedin_apply_to_job).List invitations: view pending received/sent connection invitations (
linkedin_list_pending_invites).List connections: browse your 1st-degree connections with optional local filtering (
linkedin_list_connections).Safety modes:
--dry-runuses local fixtures without touching LinkedIn;--read-onlyallows real reads but refuses all write tools.
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@linkedin-mcpSend a connection request to Dana Whitfield with a note about OpenTelemetry."
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
linkedin-mcp
A Model Context Protocol server that drives your own logged-in LinkedIn session in a real Chromium browser, with an explicit confirmation step in front of every write.
⚠️ Read this first
This is not a normal API client. Please read all of this before you install it.
It drives a real, logged-in browser session as you. The server launches Chromium, restores your saved LinkedIn cookies, and clicks the same buttons a human would.
It does not use the official LinkedIn API. There is no OAuth app, no partner agreement, and no supported integration behind it. It is browser automation of the public website.
Automating LinkedIn this way operates outside LinkedIn's User Agreement. LinkedIn's terms prohibit scraping and automated access to the site.
Your account can be rate-limited, restricted, or permanently banned. That risk is real and it is not hypothetical. LinkedIn detects automation, and enforcement can arrive without warning and without appeal.
Your session cookies live on this machine. A successful login writes a Playwright
storageStatefile to local disk. Anyone who can read that file can act as you on LinkedIn.Use is entirely at the account owner's own risk. There is no warranty here, expressed or implied, and no mitigation for a suspended account.
Three design decisions follow from the above and are not configurable:
Single account, single user. The server has one state directory holding one saved session. There is no multi-account support and no notion of "users" — it is a local tool for the person sitting at the keyboard.
Every write action requires explicit confirmation. Posting, connecting, messaging, and applying are all two-call handshakes. A single tool call can never send anything to LinkedIn. See the next section.
The server will never solve a CAPTCHA. It will not type your password, will not clear a 2FA prompt, and will not attempt to defeat a security checkpoint. When LinkedIn puts up a challenge, the server stops and tells you to handle it yourself in a normal browser.
If any of that is unacceptable for your account, do not install this.
Related MCP server: LinkedIn MCP Server
How confirm-before-execute works
Every write tool (linkedin_create_post, linkedin_send_connection_request, linkedin_send_message, linkedin_apply_to_job) is a two-call handshake:
Call 1 — preview. Call the tool with its real arguments and no confirm. The server inspects the page, works out exactly what would happen, and returns a preview envelope. Nothing is submitted, and your daily quota is not consumed.
{
"profileUrl": "https://www.linkedin.com/in/dana-whitfield-example/",
"note": "Hi Dana — we both worked on the OpenTelemetry collector. Would like to connect."
}Call 2 — confirm. Re-issue the same call with confirm: true. Optionally echo the previewToken you were handed; if you do, the server verifies that the arguments have not changed since the preview and fails with confirmation_mismatch if they have.
{
"profileUrl": "https://www.linkedin.com/in/dana-whitfield-example/",
"note": "Hi Dana — we both worked on the OpenTelemetry collector. Would like to connect.",
"previewToken": "9f2c41ab77e05d13",
"confirm": true
}The preview envelope looks like this (a real linkedin_send_connection_request preview):
{
"status": "preview",
"action": "linkedin_send_connection_request",
"executed": false,
"confirmationRequired": true,
"summary": {
"profileUrl": "https://www.linkedin.com/in/dana-whitfield-example/",
"name": "Dana Whitfield",
"headline": "Staff Engineer, Observability",
"note": "Hi Dana — we both worked on the OpenTelemetry collector. Would like to connect.",
"noteLength": 88,
"notePreview": "Hi Dana — we both worked on the OpenTelemetry collector. Would like to connect.",
"connectionDegree": 2,
"connectionDegreeLabel": "2nd",
"connectPathway": "direct",
"alreadyPending": false,
"wouldSucceed": true
},
"previewToken": "9f2c41ab77e05d13",
"quota": {
"action": "connectionRequests",
"used": 3,
"cap": 20,
"remaining": 17,
"resetsAt": "2026-08-25T07:00:00.000Z",
"allowed": true
},
"warnings": [],
"howToConfirm": "Nothing has been sent to LinkedIn yet. To execute, re-issue the exact same `linkedin_send_connection_request` call with `confirm: true` (optionally echoing `previewToken: \"9f2c41ab77e05d13\"` so the arguments are verified as unchanged)."
}Notes on reading a preview:
executed: falseandconfirmationRequired: trueare always present on a preview. An executed result instead carries"status": "executed","executed": true, and aresultobject.warningsis where the server tells you about anything it is unhappy about — a truncated post, an over-long connection note, an exhausted quota, dry-run mode.If the preview says the call cannot succeed (already connected, invitation already pending, no Connect control, not a 1st-degree connection),
howToConfirmsays so plainly and confirming will be refused withinvalid_inputwithout spending quota.previewTokenis a short digest over the action name and a canonicalized copy of the payload. It is a mismatch guard, not a security token.
Requirements
Node.js 20 or newer (
"engines": { "node": ">=20" }).A desktop environment that can open a visible browser window — interactive login requires one.
Playwright's Chromium build (installed below).
Setup
Install dependencies:
npm installDownload the Chromium build Playwright drives:
npx playwright install chromiumCreate your environment file (every variable in it is optional, and it holds no credentials):
cp .env.example .envCreate your config file (daily caps and timeouts only):
cp config.example.json config.jsonCompile TypeScript to dist/:
npm run buildFirst run, in order
Nothing here touches your LinkedIn account until the very last step.
npm installnpx playwright install chromiumcp .env.example .envandcp config.example.json config.json— both optional, neither holds credentialsnpm run buildnpm test— 324 hermetic unit cases; no browser, no networknpm run verify:dry— drives all 11 tools against local fixtures, so you find out the wiring works before pointing it at your account (details)Register the server with your MCP client, with
--dry-runinargsfirst (details). Confirm your client lists 11 tools and that a write tool returns a preview.Drop
--dry-runfromargs, restart your client, and calllinkedin_login(details). This is the first step that reaches linkedin.com. A visible Chromium window opens and you sign in by hand.Call
linkedin_session_statusto confirm the saved session works.Optional, once you have a session:
npm run verify:livethennpm run verify:read-tools— the two read-only checks that tell you whether the selectors still match today's LinkedIn (details). Both refuse to write anything.
Step 8 is the boundary. Everything before it is reversible by deleting a directory.
Logging in
There is no credential configuration anywhere in this project — by design. You sign in by hand, once, in a real browser window.
Call the
linkedin_logintool. It takes no arguments.A real, visible Chromium window opens on LinkedIn's login page. It is visible even if you configured
headless: true; interactive login always forces a headed browser.You type your email and password, and you clear whatever LinkedIn asks for next — an SMS or authenticator code, an email PIN, a device confirmation, a CAPTCHA. The server does not type credentials, does not read the password field, and does not touch challenge widgets. It just polls every two seconds, waiting to see a signed-in identity element in LinkedIn's navigation.
Take your time. The wait is bounded by
loginTimeoutMs, which defaults to300000(five minutes). Raise it inconfig.jsonif you need longer.Once LinkedIn shows a signed-in feed, the browser session is written to
storageState.jsoninside the state directory, with file permissions0600(owner read/write only). That file is gitignored.Every later tool call reuses that saved session. You should not need to log in again for weeks.
Check on the session at any time with linkedin_session_status (no arguments). It loads the feed once and reports:
{
"valid": true,
"lastVerified": "2026-08-24T18:42:10.114Z",
"sessionSavedAt": "2026-08-11T09:03:55.002Z"
}When it reports valid: false it includes a reason (for example, that LinkedIn redirected the feed to its sign-in page). No cookie or session content is ever returned by this tool, or by any other.
Sessions expire. When they do, tools start failing with session_expired and the fix is always the same: run linkedin_login again and sign in by hand.
Rate limits & pacing
The server enforces its own daily caps and inserts a randomized delay before browser actions. This is the single most important protection your account has, and the defaults are conservative on purpose.
config.example.json — copy it to config.json and edit:
{
"dailyCaps": {
"connectionRequests": 20,
"messages": 30,
"posts": 5,
"jobApplications": 10
},
"delayRangeMs": {
"min": 1500,
"max": 6000
},
"headless": false,
"navigationTimeoutMs": 30000,
"actionTimeoutMs": 15000,
"loginTimeoutMs": 300000
}The four daily caps
Cap | Limits | Default |
| Invitations sent by | 20 / day |
| Messages sent by | 30 / day |
| Posts published by | 5 / day |
| Applications submitted by | 10 / day |
How they behave:
A cap is only consumed on a confirmed execution. Previews report your current quota but never spend it, and a call the server refuses outright does not spend it either.
Counts are persisted to
counters.jsonin the state directory, so a server restart does not reset them.When a cap is reached, the tool fails with
rate_limitedinstead of acting. Setting a cap to0disables that action entirely.Every preview and executed envelope carries a
quotablock withused,cap,remaining,resetsAt, andallowed.
Randomized delay
delayRangeMs is the range the server sleeps for, uniformly at random, before browser actions — by default 1500 ms to 6000 ms. Randomization matters: a fixed interval is a machine signature. min may equal max (a fixed delay) and min must not exceed max, or config loading fails with config_invalid.
Caps reset at local midnight
Counters are keyed on the local calendar date, so all four caps reset at midnight in your own timezone — not at UTC midnight, and not on a rolling 24-hour window. resetsAt in the quota block is that next local midnight, serialized as a UTC ISO timestamp.
Start lower than the defaults
The shipped defaults are a ceiling, not a recommendation. If your account is new, has few connections, or has never been automated before, start well below them — say connectionRequests: 5, messages: 5, posts: 1, jobApplications: 2 — and raise them slowly over weeks while watching for LinkedIn warnings. A burst of activity that looks nothing like your normal usage is exactly what gets accounts restricted.
Tool reference
Eleven tools, in the order the server registers them.
The four scrape/jobs tools below (
linkedin_scrape_profile,linkedin_scrape_feed,linkedin_search_jobs,linkedin_apply_to_job) are documented from the shared contract insrc/types.tsandsrc/selectors.ts. If your client'stools/listoutput disagrees with an argument name here,tools/listis authoritative — the server always reports its real schema.
linkedin_login
Opens a visible Chromium window and waits for you to sign in yourself, including any 2FA or CAPTCHA step. Saves the session to local disk on success. Takes no arguments. Not available under --dry-run.
{}linkedin_session_status
Reports whether the saved session still works, when it was saved, and when it was last verified. Loads the feed once to check. Read-only. Always reports valid under --dry-run. Takes no arguments.
{}linkedin_create_post (write — confirmation required)
Publishes a text post to your own feed. Consumes the posts cap.
Preview:
{
"text": "Spent the week reading Playwright's tracing internals. Notes soon.",
"visibility": "connections"
}Confirm:
{
"text": "Spent the week reading Playwright's tracing internals. Notes soon.",
"visibility": "connections",
"confirm": true
}text— required, 1 to 3000 characters. Over ~1300 characters LinkedIn collapses the post behind "see more"; the preview warns you.visibility— optional,"public"or"connections". Defaults to"public".mediaUrl— optional URL. The server never fetches remote media. Passing it warns at preview and is hard-refused withinvalid_inputon confirm.previewToken— optional; echo it to have your arguments verified as unchanged.confirm— optional boolean; must be exactlytrueto execute.
linkedin_send_connection_request (write — confirmation required)
Sends a connection invitation, optionally with a note. Consumes the connectionRequests cap.
Preview:
{
"profileUrl": "https://www.linkedin.com/in/dana-whitfield-example/",
"note": "Hi Dana — we both worked on the OpenTelemetry collector. Would like to connect."
}Confirm:
{
"profileUrl": "https://www.linkedin.com/in/dana-whitfield-example/",
"note": "Hi Dana — we both worked on the OpenTelemetry collector. Would like to connect.",
"previewToken": "9f2c41ab77e05d13",
"confirm": true
}profileUrl— required, non-empty. A full profile URL or a bare vanity slug.note— optional, at most 300 characters. Notes over 200 characters need LinkedIn Premium on many accounts, so the preview warns above 200.previewToken,confirm— as above.
Refused with invalid_input before touching your quota if the person is already a 1st-degree connection, an invitation is already pending, or the page exposes no Connect control.
linkedin_send_message (write — confirmation required)
Sends a direct message. Consumes the messages cap.
Preview:
{
"profileUrlOrConversationId": "https://www.linkedin.com/in/dana-whitfield-example/",
"text": "Thanks for the pointer to the collector RFC — that answered my question."
}Confirm:
{
"profileUrlOrConversationId": "https://www.linkedin.com/in/dana-whitfield-example/",
"text": "Thanks for the pointer to the collector RFC — that answered my question.",
"confirm": true
}profileUrlOrConversationId— required, non-empty. Either a profile URL/slug, or an existing conversation thread id (as in/messaging/thread/<id>/).text— required, 1 to 8000 characters.previewToken,confirm— as above.
In profile mode the recipient must be a 1st-degree connection; anyone else is refused with not_connected before quota is spent. This tool never sends InMail, and it never presses Enter in the composer — it clicks Send.
linkedin_scrape_profile
Reads one profile and returns a structured Profile: name, headline, about, location, connection degree, whether it is your own profile, experience entries, education entries, and skills. Read-only.
{
"profileUrl": "https://www.linkedin.com/in/dana-whitfield-example/"
}linkedin_scrape_feed
Reads recent posts from your feed and returns FeedPost entries: author name and headline, text, post URL, like and comment counts, and posted-at. Read-only.
{
"count": 20
}linkedin_search_jobs
Runs a LinkedIn job search and returns JobListing entries: jobId, title, company, location, whether it is Easy Apply, and the job URL. Read-only.
{
"keywords": "site reliability engineer",
"location": "Berlin, Germany",
"easyApplyOnly": true,
"count": 25
}keywords is required. Everything else is optional:
location— free-text place name, mapped onto LinkedIn's ownlocationparameter.easyApplyOnly— restricts results to Easy Apply postings (LinkedIn'sf_ALfacet). Worth setting whenever you intend to apply through this server, sincelinkedin_apply_to_jobhandles Easy Apply only.datePosted—"past24h","pastWeek"or"pastMonth".experienceLevel—"internship","entry","associate","midSenior","director"or"executive".remote—truerestricts results to remote roles.count— how many postings to return (default 25, max 100). Results are lazy-loaded, so a largecountmeans repeated scrolling with a randomized pause between each, and can take a while.
Those four facets may be passed either at the top level, as above, or grouped inside a filters object — both are accepted, and filters wins if you pass the same key twice:
{
"keywords": "site reliability engineer",
"filters": { "easyApplyOnly": true, "datePosted": "pastWeek", "remote": true },
"count": 50
}Cards that LinkedIn renders without a usable job id (promoted slots, placeholders) are skipped rather than returned half-populated, and counted in the skipped field of the response. The response also echoes the searchUrl it used, so you can open the identical query in your own browser.
linkedin_apply_to_job (write — confirmation required)
Submits a LinkedIn Easy Apply application. Consumes the jobApplications cap.
Preview:
{
"jobId": "3912847561",
"resumePath": "/Users/parthbansal/Documents/resume.pdf"
}Confirm:
{
"jobId": "3912847561",
"resumePath": "/Users/parthbansal/Documents/resume.pdf",
"confirm": true
}jobId— required. A bare job id, a/jobs/view/<id>/URL, a search URL carrying?currentJobId=, or a job urn all resolve to the same id.resumePath— optional absolute path to a resume file on this machine. A missing file fails withfile_not_found. The file's contents are never logged.Postings that hand off to an external applicant-tracking system fail with
external_application; postings without an Easy Apply control fail withnot_easy_apply. Read the preview before confirming — it is your only chance to see which questions the form is about to answer on your behalf.previewToken,confirm— as above.
linkedin_list_pending_invites
Lists pending invitations as PendingInvite entries: name, headline, profile URL, sent-at, and direction. Read-only.
{
"direction": "received",
"count": 25
}direction— optional,"received"(someone invited you) or"sent"(you invited them). Defaults to"received".count— optional integer, 1 to 100. Defaults to 25.
linkedin_list_connections
Lists your connections as ConnectionSummary entries: name, headline, profile URL, and connected-at. Read-only.
{
"count": 50,
"query": "observability"
}count— optional integer, 1 to 200. Defaults to 50.query— optional. A local, case-insensitive substring filter applied to the connections the server already read; it is not sent to LinkedIn as a search.
MCP client configuration
The server speaks JSON-RPC over stdio and is meant to be launched by your MCP client, not by hand. Build first (npm run build), then point your client at dist/server.js.
Because the server resolves config.json, the state directory, and fixtures/ relative to its working directory — and your MCP client's working directory is usually not this project — it is worth setting the absolute paths explicitly in the env block.
Claude Code / Claude Desktop
In claude_desktop_config.json:
{
"mcpServers": {
"linkedin": {
"command": "node",
"args": ["/Users/parthbansal/Desktop/claude code/linkedin-mcp/dist/server.js"],
"env": {
"LINKEDIN_MCP_CONFIG": "/Users/parthbansal/Desktop/claude code/linkedin-mcp/config.json",
"LINKEDIN_MCP_STATE_DIR": "/Users/parthbansal/Desktop/claude code/linkedin-mcp/.linkedin-mcp",
"LINKEDIN_MCP_LOG_LEVEL": "info"
}
}
}
}Cursor
In .cursor/mcp.json:
{
"mcpServers": {
"linkedin": {
"command": "node",
"args": ["/Users/parthbansal/Desktop/claude code/linkedin-mcp/dist/server.js"],
"env": {
"LINKEDIN_MCP_CONFIG": "/Users/parthbansal/Desktop/claude code/linkedin-mcp/config.json",
"LINKEDIN_MCP_STATE_DIR": "/Users/parthbansal/Desktop/claude code/linkedin-mcp/.linkedin-mcp",
"LINKEDIN_MCP_LOG_LEVEL": "info"
}
}
}
}Generic stdio client (for example Codex CLI)
Any client that launches an stdio MCP server needs the same three things — a command, its arguments, and an environment:
{
"name": "linkedin",
"command": "node",
"args": ["/Users/parthbansal/Desktop/claude code/linkedin-mcp/dist/server.js"],
"env": {
"LINKEDIN_MCP_CONFIG": "/Users/parthbansal/Desktop/claude code/linkedin-mcp/config.json",
"LINKEDIN_MCP_STATE_DIR": "/Users/parthbansal/Desktop/claude code/linkedin-mcp/.linkedin-mcp",
"LINKEDIN_MCP_LOG_LEVEL": "info"
}
}Codex CLI uses TOML rather than JSON, but the mapping is one-to-one: command = "node", args = ["/Users/parthbansal/Desktop/claude code/linkedin-mcp/dist/server.js"], plus an [env] table.
Safe experimentation
Add "--dry-run" to args to register a fully-functional server that cannot touch your account:
"args": [
"/Users/parthbansal/Desktop/claude code/linkedin-mcp/dist/server.js",
"--dry-run"
]In dry-run mode no request reaches linkedin.com and no post, invitation, message, or application is ever submitted. This is the right way to let an agent explore the tool surface for the first time.
Other flags the server accepts: --read-only, --headless, --config <path>, --log-level <debug|info|warn|error>, and -h / --help (which prints to stderr, because stdout carries the protocol). Unknown flags are a hard error — a mistyped --dry-runn must never leave you believing you are in dry-run mode when you are not. Precedence throughout is command-line flags > environment > config.json > defaults.
Every environment variable is documented in .env.example; all of them are optional, and none of them hold credentials.
Read-only mode
--read-only is the other safety switch, and it is not the same promise as --dry-run:
reaches linkedin.com? | can change your account? | |
| no — local fixtures only | no |
| yes — the real site, real data | no |
neither | yes | yes, after you confirm |
Use it when you want an agent to read your actual LinkedIn — your real feed, your real connections, live job listings — with no possibility of a write. The four write tools (linkedin_create_post, linkedin_send_connection_request, linkedin_send_message, linkedin_apply_to_job) come back as read_only_mode errors; the other seven work normally.
Three details worth knowing:
The refusal happens before the tool runs, in the one wrapper every tool is registered through (
src/server.ts), so there is no argument shape that slips past it and no per-tool check anyone can forget to add. All eleven tools stay listed — a client discovers the full surface and learns which ones are unavailable by calling them, rather than seeing a mysteriously short tool list.Previews are refused too, not just
confirm: true. That is deliberate and it is the non-obvious part:linkedin_apply_to_jobbuilds its preview by opening the job and clicking Easy Apply to read the form, which LinkedIn can record as a started application. A bar that allowed "just the preview" would not have been read-only.linkedin_loginstays available. It writesstorageState.json, so it is notreadOnlyHint, but it changes nothing on LinkedIn — and refusing it would make read-only mode impossible to authenticate in the first place.
Set it however suits your client: --read-only on the command line, LINKEDIN_MCP_READ_ONLY=1 in the environment, or "readOnly": true in config.json. It defaults to off, so adding this feature cannot silently change an existing install. A malformed value (LINKEDIN_MCP_READ_ONLY=enabled) is a config_invalid error rather than a guess — guessing would be dangerous in either direction. Startup says so on stderr, twice, so the mode is never in doubt:
{"level":"warn","msg":"READ-ONLY: reads go to the real linkedin.com, but linkedin_create_post, linkedin_send_connection_request, linkedin_send_message and linkedin_apply_to_job will be refused with `read_only_mode`","readOnly":true,"refused":4}
{"level":"info","msg":"linkedin-mcp ready on stdio","version":"0.1.0","tools":11,"dryRun":false,"readOnly":true,"headless":false}This mode is also what npm run verify:read-tools runs under — it will not start unless that second line reports readOnly: true and dryRun: false.
Development
Type-check without emitting:
npm run typecheckRun the unit tests:
npm testRecompile on save:
npm run dev--dry-run
npm run dry-run starts the built server with --dry-run. In that mode the browser is pointed at local HTML fixtures in fixtures/ instead of linkedin.com, and the final submit click of every write flow is skipped, so a confirmed action walks the entire code path — validation, quota check, dialog interaction — and then stops short of doing anything. Executed results carry dryRun: true, and previews warn you that dry-run is on. linkedin_login is unavailable, and linkedin_session_status always reports valid.
The fixtures cover the profile page (in three variants — a 2nd-degree profile with a direct Connect button, a 2nd-degree profile whose Connect sits behind the "More" menu, and a 1st-degree connection with a working message composer), the feed, job search, a job detail page for both Easy Apply and an external applicant-tracking system, messaging, invitations, and connections. Develop here. There is no reason for day-to-day work on this server to touch your real account.
Which fixture a URL resolves to is decided by an ordered list of substring rules in src/fixtures.ts. The order is load-bearing — /mynetwork/invite-connect/connections/ contains /mynetwork/invit, and /in/tomas-eriksen contains /in/ — so specific rules sit above general ones and tests/fixtures.test.ts locks that ordering in place.
Verifying without LinkedIn
npm run verify:dryThis boots the compiled server in --dry-run, speaks JSON-RPC to it over stdio as a real MCP client would, and exercises all 11 tools across 23 cases — every read tool against its fixture, every write tool through a full preview → confirm handshake, and the refusals that matter (a stale previewToken, an over-long post, a message to a 2nd-degree profile, an external job posting, linkedin_login under dry-run). It asserts the envelope contracts too: a preview must report executed: false, a confirm must report executed: true, and stdout must carry nothing but JSON-RPC.
It refuses to run at all unless the server's own startup line confirms dryRun: true, so it cannot accidentally act on your account. It needs a working Chromium — everything but session_status opens a browser page — and it exits non-zero on any failure, sorting them into selector/logic failures (the server is wrong) and harness failures (the sweep is wrong).
Validating selectors against real LinkedIn
npm test proves the pure logic. npm run verify:dry proves the tools drive a page correctly — but against fixtures this project wrote. Neither can tell you whether a selector still matches today's LinkedIn. Only a live run can, and a live run against your own account is exactly where an accidental click is unacceptable. So:
npm run build && npm run verify:liveThis opens allow-listed LinkedIn pages with your saved session, asks every selector chain "do you match anything visible?", and prints what it saw. It fills no field, submits no form, and clicks no button that starts a write.
Each page gets a detailed section — what was requested, what URL actually came back, what kind of page it was judged to be, and one block per selector:
── Home feed ──
requested https://www.linkedin.com/feed/
observed https://www.linkedin.com/feed/
page kind feed
✓ post.startPostButton — found
matched: button.share-box-feed-entry__trigger
✓ feed.post — found (fallback #2 of 4)
matched: div.feed-shared-update-v2
✗ feed.likeCount — not found
verdict: likely-empty-state (content layer)
tried: 3 candidate(s)
- span.social-details-social-counts__reactions-count no match
why: …
next: …
skipped on this page (9):
– post.editor — inside a dialog that can only be opened by starting a write action…followed by the flat one-line-per-selector rollup, aggregated across every page (found anywhere counts as found):
SELECTOR VALIDATION
✓ profile.name — found
✓ profile.headline — found
⚠ connections.connectionCard — matched an empty container (not validated)
✗ jobs.easyApplyButton — not found
~ feed.likeCount — unprovable under GET-only — prove it with `npm run verify:read-tools`
✓ messaging.messageBox — found✓ records which candidate matched and flags when it was a fallback rather than the primary hook.
⚠ is the one verdict worth explaining, because it is the failure mode this report exists to prevent. LinkedIn's newer pages paint a card's outer shell before (or without) its contents, so a container selector can match 36 elements on a page that holds none of the data those cards are supposed to hold. Every child selector probed inside such a shell then misses — and a naive reading blames those children. So a container that matched an element but contained none of the children the manifest expects inside it is reported as not validated, its children's misses are re-classified as "nothing can be concluded", and a required empty container fails the run exactly like a miss.
✗ is not automatically a selector bug — every miss is classified (missing auth / wrong page state / empty container / blocked request / POST-rendered / legitimate empty state / DOM change), the candidates tried are listed with their match counts, and any equivalent element found on the page is dumped with its tag, role, aria-label, id, data-* and text so you can judge for yourself. The closing fixes block is split by how strong the evidence is: OBSERVED means an equivalent element was actually seen on the page and its attributes are printed for you; SUSPECTED means only that a selector produced nothing and no cheaper explanation applied — a "go and look in your own browser" prompt, never an instruction to rewrite a selector. The script never edits selectors.ts for you.
~ is the honest admission in this report. LinkedIn serves some pages — the feed, profiles, invitations, connections — from an RSC payload that arrives over a POST, and safety layer 2 aborts every POST before it leaves the machine. Those selectors therefore cannot match here, no matter how correct they are. Rather than print them beside real misses, where they read as 36 simultaneous DOM changes, they are pulled into their own UNPROVABLE UNDER GET-ONLY block, excluded from the missed count, and pointed at the one thing that can prove them: npm run verify:read-tools (below). The fix is never to widen the request filter.
– means the element only exists inside a dialog that opens by starting a write, or only after one completes; unreachable read-only, so it is reported in a closing NOT VALIDATED block rather than hidden.
Exit codes: 0 = every required selector this run could test was found, 1 = a required selector missed or matched only an empty container, 2 = the run never happened (no build, no session, a challenge, a launch failure). Non-required misses are reported but do not fail the run, because most are legitimate empty states. A ~ key cannot fail the run either — the run had no way to test it, and letting profile.name fail every single time would make exit 1 mean nothing. That is what makes the pairing below load-bearing rather than optional: verify:read-tools is where those keys can actually fail.
Flags:
flag | effect |
| Print exactly what would be probed and exit. Opens no browser, sends no request. Start here. |
| Also validate against another member's profile — needed for |
| Job-search keywords (default |
| Job-search location (default: none). |
| Comma-separated subset of |
| Open the profile "More" overflow menu so |
| Run Chromium headless. Default is headed, so you can watch it. |
| Save a local screenshot of each page visited. Local only, never uploaded. |
| Write the full machine-readable report to a file. |
The safety posture is five independent layers, each sufficient on its own:
No write tool is imported. The script does not load
dist/registry.jsand cannot invoke a write tool even by accident. It also never uses a tool's preview half — see the read-only note above for whylinkedin_apply_to_job's preview is not safe.Route-level kill switch. Every request is inspected; anything not GET/HEAD/OPTIONS is aborted before it leaves the machine, as is any URL carrying a state-changing marker —
logoutabove all, because LinkedIn's sign-out is reachable by a plain GET and a naive "GET is safe" rule would destroy the very session you asked to verify.Navigation allowlist. Every
gototarget must passisAllowedValidationUrl. An unrecognized LinkedIn path is refused rather than assumed harmless.No clicking. The only click available is opening the profile overflow menu, under the explicit
--probe-menusopt-in. Connect / Send / Submit / Apply are never even located as click targets.Challenge hard stop. A CAPTCHA, checkpoint or auth wall ends the run with instructions to clear it in your own browser. Nothing is solved, bypassed, or retried.
It needs a session it did not create: if storageState.json is missing or LinkedIn no longer accepts it, the script stops and tells you to run linkedin_login yourself. It never types a credential. It also refuses to run when dry-run is configured — validating fixtures against fixtures would prove nothing.
One privacy note: both --screenshots and --json write files containing real content from your account — page images, profile text, aria-labels. Nothing uploads them. Screenshots land in the gitignored state directory (.linkedin-mcp/screenshots/), but the --json path is wherever you point it, so choose it deliberately or keep it inside the state directory.
The pure logic underneath it (the selector manifest, the page classifier, the URL allowlist, the miss classifier) is exported from src/validation.ts and covered by tests/validation.test.ts, so the safety rules are provable without a browser.
Proving the selectors verify:live structurally cannot
npm run build && npm run verify:read-toolsSafety layer 2 above is a hard kill switch: every non-GET request is aborted. That is the layer worth keeping — but it has a consequence. LinkedIn's feed, profile, invitations and connections pages fetch their actual content over POST /flagship-web/rsc-action/…, so under verify:live those pages render a shell and 36 selectors miss for a reason that has nothing to do with whether they are correct. No amount of re-running fixes that, and the obvious "fix" — allowing those POSTs — would trade a real safety guarantee for a diagnostic convenience.
This harness proves them the other way round, at no new safety cost. It boots the compiled server with --read-only and calls the five read tools you already use over stdio:
tool | what its output proves |
|
|
|
|
|
|
|
|
|
|
The reasoning is simple: if linkedin_scrape_profile comes back with a name, a headline and three job titles, then the selectors that read them matched real DOM on the real site. A field that comes back empty on a page that did return records is the actual signal — that is a selector to look at.
Why this is not a new hole in the safety posture:
These five tools never click a write control. Not in preview, not in confirm — there is no write path in them to reach. They are the same code paths you invoke from your MCP client every day.
No new exception to the kill switch. The harness does not touch it. It runs the server normally, which is where those POSTs were always allowed.
Read tools consume no daily quota. The caps in
RateLimitercoverconnectionRequests,messages,postsandjobApplicationsonly, so a validation run cannot eat into your posting or connecting budget.Five gates run before any page work, in this order: the call plan is checked for a mutating tool, a
confirm/previewTokenargument, an out-of-range count and a non-httpsprofile URL; the server's own startup line must reportreadOnly: trueanddryRun: false; every planned tool must exist intools/listand advertiseannotations.readOnlyHint: true; andlinkedin_session_statusmust come back valid. Averification_requiredanywhere is a hard stop — no challenge is ever solved, bypassed or retried.
It needs a real signed-in session (run linkedin_login first) and it will not run under dry-run — fixture data would prove nothing about live selectors. Output is per-field, with the manifest keys each field carries:
profile (your own) linkedin_scrape_profile
records 1 · page profile
proven name 1/1 → profile.name
proven experience[] 1/1 → profile.experienceItem
proven experience[].title 3/3 → profile.itemTitle
empty experience[].description 0/3 → profile.itemDescription
note: many roles genuinely have no description
no-records skills[] 0/0 → profile.skillItemproven = at least one populated value, so the selector matched. empty = records came back and not one carried this field — this is the finding, and the manifest's own note on that field is printed beside it so you can weigh "the selector broke" against "nobody filled this in". no-records = the collection was empty, so nothing was observable (a genuinely empty invitations list reads this way, which is why it is not counted as a failure). not-observed = the tool did not run, so nothing was measured. Anything no read tool reads at all — feed.seeMoreButton and the four profile section containers, five keys in total — is listed separately as hand-check-only rather than left as a silent gap in the coverage claim.
Flags — note these take =, unlike verify:live's space-separated form, and an unrecognized argument exits 2 rather than being ignored: --plan (print the six planned calls and exit, opening nothing), --headed, --profile=<url>, --keywords=<text>/--location=<text>, --feed-count=<n>/--jobs-count=<n>/--invites-count=<n>/--connections-count=<n> (each capped at 50), --direction=received|sent|both, --only=<tool>, --state-dir=<path>, --timeout=<ms>, --json[=<path>], --verbose/-v.
The --json report lands in .linkedin-mcp/ by default and contains real content from your account, so the same privacy note as --json above applies — and the harness refuses to write to validation-report.json, so a run cannot clobber the live validator's report.
Exit codes: 0 = nothing came back measurably empty. 1 = at least one field was empty on a page that did return records — go look at that selector. 2 = the run never happened (no build, a gate refused the plan, no session, a challenge, Chromium could not launch).
Tests
The suite is run by Vitest (tests/**/*.test.ts) and currently covers the rate limiter (src/rateLimiter.ts), the config loader (src/config.ts), fixture routing (src/fixtures.ts), the live-validation logic (src/validation.ts), and read-only mode — 324 cases in five files. All of them are hermetic by construction: every case runs against a fresh mkdtemp directory, the environment is passed in explicitly rather than read from process.env, and the rate limiter's clock, sleep, and randomness are injected through RateLimiterDeps. They make no network calls and launch no browser.
Anything that needs a browser lives in the dry-run sweep above, not in the unit suite — which is why npm test finishes in under a second and needs nothing installed beyond node_modules.
Conventions worth knowing before you edit
Nothing may write to stdout. stdout is the JSON-RPC channel; one stray byte desynchronizes framing and the client drops the connection. All diagnostics go to stderr through the
Logger.The project is ESM with
moduleResolution: "NodeNext", so relative imports must end in.jseven in TypeScript source.src/types.tsandsrc/errors.tsare the shared contract.src/selectors.tsis strings and pure functions only.
Troubleshooting
"I started it and nothing happens" — that is success
Run npm start by hand and you will see one line on stderr and then apparent silence:
{"ts":"...","level":"info","msg":"linkedin-mcp ready on stdio","version":"0.1.0","tools":11,"dryRun":false,"readOnly":false,"headless":false}That is a healthy server, not a hang. An MCP stdio server is not a daemon with a port and not a CLI that prints a result and exits. It reads JSON-RPC requests from stdin and writes responses to stdout, so after announcing itself it blocks waiting for a client to say something. With no client attached, there is nothing to say, and a correct server stays quiet rather than printing anything to stdout — one stray byte there would desynchronize the protocol framing.
So: npm start is not how you use this. Register the server with an MCP client (below) and let the client spawn it. If you want to prove it responds while it is sitting there, paste a request into its stdin and press Return — a tools/list reply should come straight back:
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | node dist/server.js --dry-runPress Ctrl-C to stop a hand-started server. It shuts down on SIGINT/SIGTERM and logs shutting down.
Two related symptoms:
Your client shows 0 tools, or "server failed to start". Almost always the path in
argsis wrong ordist/was never built. Use an absolute path todist/server.js, runnpm run build, and check your client's own MCP log — the server's stderr ends up there, andconfig_invalidor a Node module-resolution error will be sitting in it.You see the ready line but every tool fails. Check what that line says.
dryRun:truemeans fixtures only, and nothing you do will reach LinkedIn;not_authenticatedon every call means there is no saved session yet, so runlinkedin_login.
Error codes
Failures come back as MCP tool errors carrying a stable code. The ones you are most likely to see:
verification_required — LinkedIn has put up a CAPTCHA, a checkpoint, or an authwall. The server stops here on purpose and will never try to solve it. Open linkedin.com in your normal browser, clear the challenge as yourself, then run linkedin_login again. If this keeps happening, treat it as a signal to lower your daily caps.
session_expired — the saved session no longer authenticates, or LinkedIn redirected the feed to its sign-in page. Run linkedin_login and sign in by hand again. linkedin_session_status will show you the specific reason.
selector_not_found — the server could not find an element it needed. This almost always means LinkedIn changed its markup, not that you did anything wrong. Every DOM selector in the project lives in src/selectors.ts, which is the single maintenance point: find the relevant candidate list, add a new selector to the front of it, and rebuild. Candidates are ordered lists, so an added selector does not break the old ones. Running with --log-level debug tells you which lookup failed.
rate_limited — you have hit a daily cap. The error and the quota block tell you which one and when it resets (next local midnight). Wait it out, or raise that cap in config.json — but raising a cap is exactly the behaviour that gets accounts restricted, so raise it deliberately.
external_application — the job posting hands applicants off to an external applicant-tracking system rather than LinkedIn's Easy Apply form. The server will not fill in third-party sites. Open the job in a browser and apply there.
not_connected — you tried to message someone who is not a 1st-degree connection. Send a connection request first, wait for it to be accepted, then message. The server will not work around this with InMail.
read_only_mode — the server was started with --read-only (or LINKEDIN_MCP_READ_ONLY=1, or "readOnly": true) and you called one of the four write tools. Nothing was attempted; the refusal happens before the tool runs. Restart without the flag to allow writes. See Read-only mode.
Other codes you may encounter: not_authenticated (no session on disk yet — run linkedin_login), not_easy_apply, invalid_input, confirmation_mismatch (arguments changed between preview and confirm — preview again), navigation_failed, browser_error, dry_run_unsupported, config_invalid (a malformed config.json, reported before the server starts), and file_not_found.
What this does NOT do
No InMail. Messaging is 1st-degree connections and existing conversations only.
No external-ATS applications. Easy Apply only; anything that leaves LinkedIn is refused.
No CAPTCHA solving. No 2FA automation, no checkpoint bypass, ever.
No multi-account support. One saved session, one account, one person at the keyboard.
No remote media upload. The server never fetches a URL to attach to a post.
Security & privacy
What is stored, and where. Everything lives on this machine, under the state directory — by default <cwd>/.linkedin-mcp/, overridable with LINKEDIN_MCP_STATE_DIR (or per-path with LINKEDIN_MCP_STORAGE_STATE, LINKEDIN_MCP_USER_DATA_DIR, LINKEDIN_MCP_SCREENSHOT_DIR, LINKEDIN_MCP_COUNTERS):
Path | Contents |
| Your LinkedIn cookies and origin storage, written mode |
| The persistent Chromium profile directory |
| Today's local date plus the four action counts |
| Any diagnostic screenshots, written locally only |
Nothing is transmitted anywhere. There is no telemetry, no analytics, no crash reporting, and no phone-home of any kind. The only network destination is linkedin.com, reached through your own browser session — and under --dry-run not even that.
Screenshots are local-only. They are written to the screenshots directory for your own debugging and are never uploaded or included in tool output.
Logging is deliberately thin. Diagnostics go to stderr. Cookies, storageState contents, and resume file contents are never logged or serialized. Tool failures log the error code rather than the arguments, so a message body or a resume path cannot leak into a client's captured stderr. redactConfig strips absolute paths (and your home directory) out of the effective-configuration line the server logs at startup.
Gitignore guarantees. .gitignore excludes .env and .env.* (keeping .env.example), the whole .linkedin-mcp/ state directory, storageState.json at any depth, chromium-profile/, counters.json, screenshots/, *.log, plus node_modules/, dist/, and coverage/. Only config.example.json and your config.json stay tracked, and they contain nothing but caps and timeouts. Never commit storageState.json — it is a live credential.
Treat the state directory as a secret. Anyone who can read storageState.json can act as you on LinkedIn without a password or a 2FA code. If you think it has been exposed, sign out of all sessions from LinkedIn's own security settings, delete the file, and log in again.
Available Tools
11 toolslinkedin_apply_to_jobApply to a LinkedIn job (Easy Apply only)ADestructive
Submits a LinkedIn Easy Apply application. jobId accepts a bare id ("3812345678"), a job URL, a ?currentJobId= URL or a urn:li:jobPosting: URN. resumePath is optional — an absolute path to a local .pdf, .doc or .docx file (never uploaded anywhere except LinkedIn itself); omit it to use whatever resume is already attached to your LinkedIn profile. answers maps question text to the answer to give; keys are matched case-insensitively and by substring, so "years of experience" answers "How many years of experience do you have?". CONFIRMATION IS REQUIRED: called without confirm: true this opens the Easy Apply modal read-only, lists every question it will be asked, flags the required ones it cannot answer in unansweredRequired, closes the modal without submitting, and returns a previewToken. Re-issue the identical call with confirm: true to actually submit. Postings that apply through an employer's own site are refused with external_application and their URL handed back for you to use by hand; postings with no Easy Apply button are refused with not_easy_apply. If a required question still cannot be answered at submit time the application is abandoned with invalid_input rather than sent incomplete — this tool never submits a partial application. Counts against the daily jobApplications cap.
| Name | Required | Description | Default |
|---|---|---|---|
| jobId | Yes | ||
| answers | No | ||
| confirm | No | ||
| resumePath | No | ||
| previewToken | No |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations indicate readOnlyHint=false, destructiveHint=true, idempotentHint=false, openWorldHint=true. The description goes beyond annotations by disclosing the two-phase confirmation requirement, the refusal reasons, and that it never submits partial applications. It also clarifies that resumePath is never uploaded anywhere except LinkedIn. While it doesn't detail every failure mode, it covers essential behavioral traits beyond the annotations.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is comprehensive yet well-structured. It front-loads the core action, then details each parameter, then explains the confirmation flow and failure modes. Every sentence adds value, and it's written in a logical order. It's long but justified given the complexity.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Given the tool's complexity (5 params, nested objects, validation logic) and lack of output schema, the description provides complete guidance: parameter formats, confirmation flow, refusal cases, failure handling, and quota impact. No critical information missing for an agent to call it correctly.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema coverage is 0%, so the description must fully explain each parameter. It does: jobId formats, resumePath optionality and accepted file types, answers matching semantics (case-insensitive, substring), confirm flag behavior, and previewToken usage. This adds significant meaning beyond the bare schema properties.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description clearly states the tool's purpose: submitting a LinkedIn Easy Apply application. It specifies the verb (submits), resource (LinkedIn Easy Apply application), and key details like jobId formats and confirmation requirement. It distinguishes itself from siblings like linkedin_search_jobs by focusing on the application action.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Explicitly explains when to use: for Easy Apply postings, and when NOT to use: external applications and non-Easy Apply postings are refused. It also clarifies the confirm flag flow (preview vs submit). Mentions alternatives implicitly (handling external URL manually). Provides clear context for when this tool is appropriate versus others.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
linkedin_create_postCreate a LinkedIn postADestructive
Publishes a text post to the signed-in member’s LinkedIn feed. This is a two-step tool: called without confirm: true it only returns a preview (character count, line count, audience, and a previewToken) and touches nothing on LinkedIn; call it again with the same arguments plus confirm: true to actually publish. Counts against the daily posts cap. visibility defaults to "public" (Anyone); "connections" restricts the post to 1st-degree connections. LIMITATION: mediaUrl is accepted for validation but images and video are NOT supported — this server never performs its own network fetches, so it cannot download remote media, and confirming a call that sets mediaUrl is refused with invalid_input rather than silently publishing text only. Post without media, or attach the image by hand in LinkedIn afterwards. Posts over roughly 1300 characters are collapsed by LinkedIn behind "…see more" (a warning, not an error). Under --dry-run the composer is driven against a local HTML fixture and the final "Post" click is skipped.
| Name | Required | Description | Default |
|---|---|---|---|
| text | Yes | ||
| confirm | No | ||
| mediaUrl | No | ||
| visibility | No | ||
| previewToken | No |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already mark destructive=true, but the description adds substantial context: the two-step preview/publish mechanism, daily posts cap, visibility default, media refusal with invalid_input, 1300-character collapse warning, and dry-run behavior. This goes well beyond what annotations provide and contains no contradictions.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
While long, every sentence adds operational value: purpose first, then invocation pattern, then limitations and edge cases. There is no filler or redundant phrasing; the structure logically leads the agent through what to expect.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Covers the preview response fields, error handling for media, warning threshold, and dry-run behavior. The only notable gap is that it doesn't describe what the final publish call returns (e.g., post ID or confirmation response), which would be useful since there is no output schema. Still, this is minor relative to the volume of useful context.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema has zero descriptions, so the description carries the full burden. It explains text (content), confirm (publish gate), mediaUrl (accepted but unsupported, refused on confirm), visibility (default 'public', meaning of 'connections'), and previewToken (returned from preview). All parameters are meaningfully clarified.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description opens with a specific verb and resource: 'Publishes a text post to the signed-in member’s LinkedIn feed.' This clearly distinguishes it from siblings like send_message or scrape_profile and leaves no ambiguity about what the tool does.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
It explains the two-step invocation pattern (preview without confirm, publish with confirm) and the media limitation as a clear 'not supported' case with manual workaround. It doesn't explicitly mention sibling alternatives, but the usage context and when-to-publish guidance are strong.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
linkedin_list_connectionsList LinkedIn connectionsARead-onlyIdempotent
Lists the signed-in member’s 1st-degree connections, most recently added first (LinkedIn’s default sort). count caps how many are returned (default 50, max 200) and the list is scroll-paginated until that many are loaded, the list stops growing, or an internal scroll cap is reached. query is an optional case-insensitive substring filter applied LOCALLY to the name and headline of the connections that were loaded — it is not LinkedIn’s own search, so a match that sits far down a very long list may not be reached. Read-only: takes no confirm and changes nothing. Each entry carries name, headline, canonical profile URL, and a "connected at" timestamp when LinkedIn exposes one. total is the account-wide count from the page header when it can be read (totalSource: "page-header"), otherwise the number of cards that were loaded.
| Name | Required | Description | Default |
|---|---|---|---|
| count | No | ||
| query | No |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already declare readOnlyHint, openWorldHint, idempotentHint, and destructiveHint, so the bar is lower. The description adds significant behavioral detail beyond that: pagination mechanics (scroll-paginated until count met, stops growing, internal cap), how the query filter is applied locally, the total count source behavior, and the per-entry fields including 'connected at' timestamp. This goes well beyond the structured hints.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is dense but efficient—about 5 sentences covering all important aspects. It is front-loaded with the core purpose and sorted order, then details parameters and output. Could be slightly more concise (e.g., merging some clauses) but every sentence adds value without fluff.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
For a tool with only 2 optional parameters and no output schema, the description covers all necessary context: purpose, sorting, pagination, parameter semantics, output fields, total count source, and read-only nature. An agent can confidently call it and interpret results without needing additional external knowledge.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema coverage is 0%, so the description must fully explain both parameters. It does: count is given a default (50), maximum (200), and describes pagination; query is defined as a case-insensitive substring filter applied locally to name and headline. This fully compensates for the lack of schema descriptions.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description clearly states the tool lists the signed-in member's 1st-degree connections with a specific sort order (most recently added first). This distinguishes it from siblings like linkedin_send_connection_request (mutating) and linkedin_scrape_profile (single profile), leaving no ambiguity about what it does.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description explains when to use it (listing connections) and clarifies that the query filter is local, not LinkedIn's search, which warns against misuse for large lists. However, it does not explicitly point to alternative tools for actions like sending requests or scraping profiles, though the sibling names make that implicit. Slight gap in explicit exclusions.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
linkedin_list_pending_invitesList pending LinkedIn invitationsARead-onlyIdempotent
Lists invitations that are still awaiting a decision. direction: "received" (the default) returns invitations other people sent you; direction: "sent" returns invitations you sent that have not been accepted yet. count caps how many are returned (default 25, max 100). Read-only: this never accepts, ignores or withdraws anything, and takes no confirm. Each entry carries name, headline, canonical profile URL, and a timestamp (the datetime attribute when LinkedIn provides one, otherwise the relative text such as "3 days ago"). An empty list is a normal result, not an error. total is how many invitation cards were found on the page, which can exceed returned when count is smaller.
| Name | Required | Description | Default |
|---|---|---|---|
| count | No | ||
| direction | No |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
The description explicitly states the tool never accepts, ignores, or withdraws anything, and takes no `confirm`, adding real behavioral context beyond the readOnlyHint annotation. It also discloses edge cases such as empty lists being normal and the difference between `total` and `returned`.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is dense but every sentence earns its place: purpose, parameter semantics, read-only guarantee, output format, and edge-case handling. It is front-loaded with the core action and then efficiently covers nuances without redundancy.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
With no output schema, the description fully explains what entries contain, how timestamps are represented, that an empty list is valid, and how `total` relates to `returned`. Combined with parameter coverage and side-effect disclosure, nothing critical is missing for calling this tool correctly.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 0%, so the description carries full responsibility for explaining parameters. It thoroughly documents the `direction` enum values with defaults, `count` default and maximum, and even explains result-field meanings that relate to the parameters, fully compensating for the schema gap.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description clearly states the tool lists 'invitations that are still awaiting a decision' and specifies the resource precisely. It also distinguishes sent vs received directions, but does not explicitly differentiate from the similar sibling tool `linkedin_list_connections`, so it falls just short of a 5.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description gives strong operational guidance: direction defaults, count limits, and read-only behavior. However, it never mentions when to prefer this tool over alternatives like `linkedin_list_connections` or `linkedin_send_connection_request`, leaving selection context implied rather than explicit.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
linkedin_loginLog in to LinkedIn (interactive)AIdempotent
Opens a visible Chromium window on LinkedIn’s login page and waits for the human to sign in themselves, including any 2FA or CAPTCHA step. This tool never types credentials and never reads the password field. When LinkedIn shows a signed-in feed, the browser session is saved to local disk (permissions 0600) and reused by every other tool. Takes no arguments. Not available under --dry-run.
| Name | Required | Description | Default |
|---|---|---|---|
No parameters | |||
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already indicate readOnlyHint=false and idempotentHint=true, but the description adds valuable context: it opens a visible browser, waits for human input, never types credentials, saves the session to local disk with permissions 0600, and is interactive. This goes beyond annotations and provides security-relevant details that affect agent behavior.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is front-loaded with the key action (opens browser, waits for human) and then covers important caveats (never types, session saved, not under dry-run) in a compact set of sentences. Every sentence contributes value, and the structure is scannable.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Given the tool's interactive nature and reliance on human input, the description covers the essential flow: what it does, how it completes (when feed appears, session saved), and constraints (no credential handling, file permissions). It doesn't specify failure handling, but for a login tool this is acceptable and the description is complete for an agent to invoke it correctly.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
The tool takes no arguments, and the schema confirms an empty properties object. The description redundantly states 'Takes no arguments.' Since there are no parameters to document, the baseline of 4 applies, and the description adds no misleading information.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description clearly states the tool's purpose: opens a visible Chromium window on LinkedIn's login page and waits for the human to sign in. It specifies the verb (opens, waits), resource (LinkedIn login page), and explicitly distinguishes itself from siblings by emphasizing it requires human interaction and never types credentials.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description communicates when to use it: when a human must authenticate interactively, including 2FA/CAPTCHA. It also notes the session is saved and reused by other tools, implying usage before authenticated operations, and states it's unavailable under --dry-run. However, it doesn't explicitly name alternatives like linkedin_session_status for checking login status, or mention when not to use it.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
linkedin_scrape_feedScrape the LinkedIn feedARead-onlyIdempotent
Reads the signed-in member's home feed and returns the posts as structured data: author name and headline, post text, permalink, reaction and comment counts, and a timestamp. count is how many posts to return (default 10, max 50). The feed is lazy-loaded, so reaching a high count means scrolling, and this tool pauses a randomized human-like interval between scrolls — a large count can therefore take a minute or more. Read-only: takes no confirm, consumes no daily quota, and never likes, comments or reposts; the only clicks are "…see more" expanders so post text is captured at full length. Posts are de-duplicated by permalink (LinkedIn recycles cards while scrolling) and sponsored or suggestion cards with no readable author are skipped. Fewer posts than requested is a normal result, not an error.
| Name | Required | Description | Default |
|---|---|---|---|
| count | No |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
The description substantially exceeds the annotations' coverage. It discloses the read-only nature (never likes/comments/reposts, no confirm, no quota), explains the lazy-loading with human-like pauses and the time implication for large counts, de-duplication by permalink, skipping of sponsored cards, and that fewer-than-requested results is normal. This rich behavioral detail helps the agent anticipate side effects and performance without contradicting the annotations.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Though longer than average, every sentence earns its place. The opening sentence states the core purpose, then parameter semantics, behavioral safety, de-dup/skipping logic, and the normal-result caveat are each addressed succinctly. The structure is front-loaded and contains no filler or repetition.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
For a tool with one parameter and no output schema, the description covers everything an agent needs: what it returns, how it behaves, potential pitfalls (time, fewer results), and safety guarantees. There are no meaningful gaps that would prevent correct invocation.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 0%, so the description must fully compensate. It does: it explains that count is how many posts to return, defaults to 10, has a max of 50, and that a larger count involves scrolling and can take a minute or more. This goes far beyond the schema's bare integer min/max, giving the agent the context needed to choose an appropriate value.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description states a specific verb ('reads') and resource ('signed-in member's home feed'), and enumerates the exact output fields (author name/headline, post text, permalink, reaction/comment counts, timestamp). This clearly distinguishes it from sibling tools like linkedin_scrape_profile, which targets profiles, and linkedin_create_post, which writes.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description unambiguously communicates that this tool is for reading the home feed, which sets it apart from siblings (profiles, jobs, messaging). However, it does not explicitly name alternative tools or state conditions when this tool should be avoided (e.g., 'if you need profile data, use linkedin_scrape_profile'), leaving that inference to the agent.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
linkedin_scrape_profileScrape a LinkedIn profileARead-onlyIdempotent
Reads a LinkedIn member profile and returns it as structured data: name, headline, about, location, connection degree, experience, education and skills. profileUrl accepts a full URL ("https://www.linkedin.com/in/john-doe"), a path ("/in/john-doe") or a bare slug ("john-doe"); omit it entirely to read the signed-in member's own profile. Read-only: takes no confirm, consumes no daily quota, and the only thing it clicks is the "…see more" expander, which reveals text already on the page. Profile sections LinkedIn hides from the viewer come back as null or [] rather than as an error — only a profile with no readable name fails (selector_not_found), because that means the page is not a profile. connectionDegree is 1, 2 or 3, and null when no badge is shown (which includes your own profile). skills is capped at 50 entries, experience at 25, education at 15.
| Name | Required | Description | Default |
|---|---|---|---|
| profileUrl | No |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Even though the annotations already declare readOnlyHint and idempotentHint, the description adds substantial behavioral detail: it consumes no daily quota, only clicks the '…see more' expander, returns null/[] for hidden sections, and specifies the sole error condition (selector_not_found). It also clarifies connectionDegree semantics and result caps, going well beyond the annotations.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is long but densely informative, with the core purpose and return fields front-loaded followed by parameter formats, behavioral notes, and edge cases. Every sentence adds operational value; no filler or tautology is present.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
With one optional parameter, strong annotations, no output schema, and no nested objects, this description covers all necessary invocation details: input formats, return fields, error behavior, and limits. An agent can correctly select and call this tool without needing additional documentation.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
The schema only defines profileUrl as a string with no description, giving 0% coverage. The description fully compensates by explaining all accepted input formats, the default when omitted, and what the tool reads from that URL. This gives an agent everything needed to pass the parameter correctly.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description states a specific verb and resource: 'Reads a LinkedIn member profile and returns it as structured data,' and enumerates the exact fields returned. It is clearly distinguishable from sibling tools like linkedin_scrape_feed and linkedin_search_jobs, which target different data sources.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description gives concrete usage context: accepted profileUrl formats (full URL, path, bare slug) and the behavior when omitted (reads the signed-in member's own profile). It does not explicitly name alternatives or when not to use this tool, but the profile-scoped purpose and sibling names make the choice reasonably clear.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
linkedin_search_jobsSearch LinkedIn jobsARead-onlyIdempotent
Searches LinkedIn job postings and returns them as structured data: job id, title, company, location, whether the posting supports Easy Apply, and a URL. keywords is required; location is a free-text place name ("London", "Remote", "New York, NY"). Four optional facets narrow the search — easyApplyOnly, datePosted ("past24h" | "pastWeek" | "pastMonth"), experienceLevel ("internship" | "entry" | "associate" | "midSenior" | "director" | "executive") and remote — each accepted either at the top level or nested inside a filters object; filters wins if you somehow pass both. count is how many postings to return (default 25, max 100); results are lazy-loaded, so a high count means scrolling with a randomized human-like pause between scrolls and can take a while. Read-only: takes no confirm and consumes no daily quota. Cards LinkedIn renders without a usable job id are skipped and counted in skipped. Pass a returned jobId to linkedin_apply_to_job, which handles Easy Apply postings only — so filter on easyApply before trying to apply.
| Name | Required | Description | Default |
|---|---|---|---|
| count | No | ||
| remote | No | ||
| filters | No | ||
| keywords | Yes | ||
| location | No | ||
| datePosted | No | ||
| easyApplyOnly | No | ||
| experienceLevel | No |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already mark read-only and idempotent, but description adds substantial behavior: lazy-loading with randomized scroll pauses, scoring and skipping of cards without job IDs (counted in `skipped`), no confirm or daily quota, and the filter precedence rule (filters wins if both present). This exceeds annotation coverage and is genuinely useful.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Although lengthy, every sentence adds value: purpose, parameters, behavior, and downstream workflow. Front-loads the core action and returns, then details. No filler; structure is logical.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
For a complex tool with 8 params, nested objects, and no output schema, the description covers return fields, skipped count, lazy-loading performance, and the apply workflow. It also clarifies default/max count and filter precedence. No critical missing information for an agent to call it correctly.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 0%, yet the description compensates by explaining each major parameter: required keywords, free-text location examples, all four facets with exact enum values (datePosted and experienceLevel), count default/max, and the nested-versus-top-level filter hierarchy. This is more than the schema provides.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
Clear verb+resource: 'Searches LinkedIn job postings' and explicitly lists returned fields (job id, title, company, location, Easy Apply support, URL). Distinguishes from siblings because no other sibling handles job search; it's the obvious entry point for discovery.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Provides clear context: explains how to use filters and count, and explicitly routes results to linkedin_apply_to_job for Easy Apply postings. Does not explicitly state exclusions (e.g., when not to use) but the workflow guidance is strong. Lacks a direct 'use instead of X' but that's not needed given sibling separation.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
linkedin_send_connection_requestSend a LinkedIn connection requestADestructive
Sends one connection invitation to a member, optionally with a note. Two-call handshake: without confirm: true this only reads the profile (name, headline, connection degree, and whether a Connect control exists at all) and returns a preview plus a previewToken — nothing is clicked and no invitation is sent. Re-issue the same call with confirm: true to send. profileUrl accepts a slug ("john-doe") or any LinkedIn profile URL. note is capped at 300 characters; free accounts are limited to 200 and LinkedIn silently truncates past its own limit, so notes over 200 characters are flagged. The request is REFUSED (with invalid_input, before any quota is spent) when the member is already a 1st-degree connection, when an invitation is already pending, or when LinkedIn offers no Connect control for that profile; the preview says so up front in that case. Counts against the daily connectionRequests cap. Under --dry-run the invitation dialog is driven against a local HTML fixture and the final Send click is skipped.
| Name | Required | Description | Default |
|---|---|---|---|
| note | No | ||
| confirm | No | ||
| profileUrl | Yes | ||
| previewToken | No |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
The description goes far beyond the annotations (readOnlyHint=false, destructiveHint=true) by detailing concrete side effects: no click or send without confirm, quota consumption, note truncation limits per account type, and refusal before quota is spent. This level of transparency is exemplary.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is dense but every sentence adds critical information. It is front-loaded with the core purpose and handshake, then layers on parameter details, refusal conditions, and quotas. No redundancy or filler.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Given the tool's complexity (two-call handshake, refusals, dry-run, quotas, note limits), the description covers every operational aspect. Without an output schema, it explains what the preview returns and what confirm does. An agent can safely and correctly invoke this tool without further context.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema coverage is 0% (no descriptions in the schema), so the description carries full burden. It explains profileUrl accepts slug or URL, note has 300-char cap (200 for free accounts with silent truncation), confirm triggers the actual send, and previewToken is part of the handshake. All four parameters are clarified.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description clearly states the tool sends a connection invitation to a member, optionally with a note, and explicitly describes the two-call handshake. It distinguishes itself from siblings like send_message or create_post by focusing on the connection request action and its preview/confirm flow.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Provides explicit usage instructions: the two-call pattern (preview then confirm), when requests are refused (already 1st-degree, pending invite, no Connect control), and the dry-run behavior. It clarifies when not to call with confirm:true immediately, covering usage conditions thoroughly.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
linkedin_send_messageSend a LinkedIn direct messageADestructive
Sends one direct message, either to a 1st-degree connection (pass a profile URL or slug) or into a conversation that already exists (pass the id from its /messaging/thread// URL). Two-call handshake: without confirm: true this only reads the profile or thread and returns a preview plus a previewToken, and nothing is typed or sent. Messaging is limited to 1st-degree connections — a profile at 2nd/3rd degree (or one whose degree cannot be read) is refused with not_connected, and InMail is never attempted. Counts against the daily messages cap. Under --dry-run everything runs against local fixtures and the final Send click is skipped.
| Name | Required | Description | Default |
|---|---|---|---|
| text | Yes | ||
| confirm | No | ||
| previewToken | No | ||
| profileUrlOrConversationId | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Beyond annotations (readOnlyHint false, destructiveHint true), the description discloses the two-call handshake, that confirm:true is required to actually send, that it counts against a daily messages cap, the dry-run behavior, and that InMail is never attempted. It also mentions the refusal condition. This is rich, actionable behavioral context that adds real value over the annotations.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is dense yet efficient, front-loading the core action and then packing critical details (handshake, restrictions, dry-run) without fluff. Every sentence adds necessary information and the structure guides the reader through the flow logically.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Given the tool's complexity (two-call handshake, degree restrictions, dry-run mode), the description covers all essential aspects an agent needs to call it correctly: return of previewToken, refusal with not_connected, daily cap effect, and dry-run behavior. Without an output schema, it adequately explains the expected response shape.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
With 0% schema description coverage, the description carries the burden of explaining parameters. It explains profileUrlOrConversationId (URL/slug or thread ID), confirm (triggers the send in the second call), and previewToken (returned from the first call). It does not explicitly describe the text parameter, but its meaning is obvious as the message body. This nearly compensates for the lack of schema descriptions.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description clearly states the tool's purpose: 'Sends one direct message' and specifies the two distinct target types (1st-degree connection via profile URL/slug, or an existing conversation via thread ID). This unambiguously distinguishes it from siblings like linkedin_create_post or linkedin_send_connection_request.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Provides explicit usage conditions: only 1st-degree connections, refusal with not_connected, and the two-call handshake required for sending. It explains when the tool will refuse and how confirmation works, though it does not explicitly name an alternative tool for other messaging scenarios. Still, the context is strong enough for an agent to decide when to use it.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
linkedin_session_statusCheck the saved LinkedIn sessionARead-onlyIdempotent
Reports whether the locally saved LinkedIn session still works, when it was saved, and when it was last verified. Loads the feed once to check; no cookie or session content is ever returned. Under --dry-run this always reports valid. Takes no arguments.
| Name | Required | Description | Default |
|---|---|---|---|
No parameters | |||
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Beyond the readOnlyHint annotation, the description reveals that it 'loads the feed once to check', which implies a network call despite being read-only. It also assures that 'no cookie or session content is ever returned' and clarifies the --dry-run behavior. These details give the agent important expectations beyond annotations.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is two sentences with no waste. The core purpose is front-loaded, followed by behavioral notes and the no-arguments statement. Every clause adds necessary information.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
For a zero-parameter, no-output-schema tool, the description fully covers what the tool does, how it checks (loads feed), what it returns (status, save time, verification time), and edge case behavior (dry-run). Nothing an agent needs to invoke it correctly is missing.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
The tool has zero parameters, and the description explicitly states 'Takes no arguments.' This meets the baseline for no-parameter tools, and the statement removes any ambiguity, so a 4 is appropriate.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description states a specific verb ('reports') and resource ('saved LinkedIn session'), and specifies the exact outputs: whether it still works, when saved, last verified. This clearly differentiates it from action-oriented siblings like linkedin_login or linkedin_create_post.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description makes it clear this is a status check tool by stating what it reports. While no explicit alternatives or when-not-to-use are mentioned, the context of sibling tool names (all performing actions) makes the intended usage obvious. A slight deduction for not explicitly saying 'use before other LinkedIn operations'.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections. Dates show when Glama detected each change.
11 tool updates
v0.1.0- First observed
linkedin_apply_to_job - First observed
linkedin_create_post - First observed
linkedin_list_connections - First observed
linkedin_list_pending_invites - First observed
linkedin_login - First observed
linkedin_scrape_feed - First observed
linkedin_scrape_profile - First observed
linkedin_search_jobs - First observed
linkedin_send_connection_request - First observed
linkedin_send_message - First observed
linkedin_session_status
TDQS
Each tool targets a distinct LinkedIn action (login, session check, create post, send connection, send message, scrape profile, scrape feed, search jobs, apply to job, list invites, list connections). Even similar functions like scraping profile vs. sending connection request are clearly separated by purpose, with no two tools overlapping in behavior.
All tools follow the 'linkedin_' prefix plus a snake_case verb_noun pattern (e.g., linkedin_create_post, linkedin_list_connections). Even the outlier 'linkedin_session_status' uses a consistent noun-like phrase, but the naming is uniform and predictable across the entire set.
With 11 tools, the server covers a broad set of LinkedIn capabilities without being bloated. Each tool serves a clear, distinct purpose, from authentication and session management to content creation, messaging, scraping, job search, and application. The count is well within the ideal 3-15 range and feels appropriately scoped for a LinkedIn assistant.
The surface covers core workflows: posting, messaging, connection requests, profile/feed reading, job search, and Easy Apply. Minor gaps exist such as accepting/rejecting invitations, editing/deleting posts, or reacting to content, but the available tools handle the most common personal LinkedIn tasks without dead ends.
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Connectors
Full LinkedIn access for AI agents: leads, messaging, and campaigns with safe limits built in.
Give AI agents the LinkedIn tools to find, qualify, engage, and follow up with prospects.
Human-in-the-loop LinkedIn outreach and a built-in sales CRM for AI agents. Safety-gated, anti-spam.
Run multi-step tasks in a real Chrome browser: persistent environments, live view, human takeover.
Related MCP Servers
- AlicenseBqualityBmaintenanceEnables full control over LinkedIn profiles through browser automation, allowing reading, editing, adding, removing entries, and publishing posts directly from conversations.41294MIT
- AlicenseNot gradedqualityDmaintenanceEnables fetching detailed LinkedIn profile data by automating a browser session with your LinkedIn cookie to access full profiles.214MIT
- AlicenseBqualityCmaintenanceEnables read-only extraction of LinkedIn profile data via MCP tools, using a local browser bridge for secure, authenticated access without exposing browser credentials.2MIT
- AlicenseAqualityBmaintenanceLets an AI assistant operate LinkedIn through an authenticated browser session, enabling profile management, posting, networking, messaging, job search, and automated applications.1003631MIT
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/bansalsahab/linkdin-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server