linkedin-mcp
Enables users to drive their own logged-in LinkedIn session through browser automation, with a mandatory preview-and-confirm step before any action is sent.
README
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.
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 install
Download the Chromium build Playwright drives:
npx playwright install chromium
Create your environment file (every variable in it is optional, and it holds no credentials):
cp .env.example .env
Create your config file (daily caps and timeouts only):
cp config.example.json config.json
Compile TypeScript to dist/:
npm run build
First run, in order
Nothing here touches your LinkedIn account until the very last step.
npm installnpx playwright install chromiumcp .env.example .envandcp config.example.json config.json— both optional, neither holds credentialsnpm run buildnpm test— 75 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.
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 |
|---|---|---|
connectionRequests |
Invitations sent by linkedin_send_connection_request |
20 / day |
messages |
Messages sent by linkedin_send_message |
30 / day |
posts |
Posts published by linkedin_create_post |
5 / day |
jobApplications |
Applications submitted by linkedin_apply_to_job |
10 / day |
How they behave:
- A cap is only consumed on a confirmed execution. Previews report your current quota but never spend it, and a call the server refuses outright does not spend it either.
- 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: --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.
Development
Type-check without emitting:
npm run typecheck
Run the unit tests:
npm test
Recompile on save:
npm run dev
--dry-run
npm run dry-run starts the built server with --dry-run. In that mode the browser is pointed at local HTML fixtures in fixtures/ instead of linkedin.com, and the final submit click of every write flow is skipped, so a confirmed action walks the entire code path — validation, quota check, dialog interaction — and then stops short of doing anything. Executed results carry dryRun: true, and previews warn you that dry-run is on. linkedin_login is unavailable, and linkedin_session_status always reports valid.
The fixtures cover the profile page (in three variants — a 2nd-degree profile with a direct Connect button, a 2nd-degree profile whose Connect sits behind the "More" menu, and a 1st-degree connection with a working message composer), 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:dry
This boots the compiled server in --dry-run, speaks JSON-RPC to it over stdio as a real MCP client would, and exercises all 11 tools across 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).
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), and fixture routing (src/fixtures.ts) — 75 cases in three files. All three 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,"headless":false}
That is a healthy server, not a hang. An MCP stdio server is not a daemon with a port and not a CLI that prints a result and exits. It reads JSON-RPC requests from stdin and writes responses to stdout, so after announcing itself it blocks waiting for a client to say something. With no client attached, there is nothing to say, and a correct server stays quiet rather than printing anything to stdout — one stray byte there would desynchronize the protocol framing.
So: npm start is not how you use this. Register the server with an MCP client (below) and let the client spawn it. If you want to prove it responds while it is sitting there, paste a request into its stdin and press Return — a tools/list reply should come straight back:
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | node dist/server.js --dry-run
Press Ctrl-C to stop a hand-started server. It shuts down on SIGINT/SIGTERM and logs shutting down.
Two related symptoms:
- Your client shows 0 tools, or "server failed to start". Almost always the path in
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.
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 |
|---|---|
storageState.json |
Your LinkedIn cookies and origin storage, written mode 0600 |
chromium-profile/ |
The persistent Chromium profile directory |
counters.json |
Today's local date plus the four action counts |
screenshots/ |
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.
推荐服务器
Baidu Map
百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。
Playwright MCP Server
一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。
Magic Component Platform (MCP)
一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。
Audiense Insights MCP Server
通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。
VeyraX
一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。
Kagi MCP Server
一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。
graphlit-mcp-server
模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。
e2b-mcp-server
使用 MCP 通过 e2b 运行代码。
Neon MCP Server
用于与 Neon 管理 API 和数据库交互的 MCP 服务器
Exa MCP Server
模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。