@cashtokenai/bi-linkedin-mcp
v0.1.0
Published
Model Context Protocol server exposing LinkedIn business-intelligence tools via the official LinkedIn API.
Maintainers
Readme
bi-linkedin-mcp
A Model Context Protocol server that exposes LinkedIn business-intelligence tools, backed by the official LinkedIn API (OAuth 2.0). It runs in two modes:
- HTTP transport (production) — a standing, network-reachable service that other projects call remotely over HTTP, guarded by a shared bearer token, with LinkedIn tokens persisted in a JSON file and auto-refreshed (authorize once).
- stdio transport (local dev) — launched as a subprocess by an MCP client such as Claude Desktop / Claude Code.
Capability matrix
| Area | Tools | Status |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| Company / org analytics | linkedin_get_organization, linkedin_organization_follower_statistics, linkedin_organization_page_statistics, linkedin_organization_share_statistics | ✅ Live (Community Management API) |
| Content & ad analytics | linkedin_list_ad_accounts, linkedin_list_campaigns, linkedin_ad_analytics, linkedin_post_social_actions | ✅ Live (Marketing Developer Platform) |
| Own profile | linkedin_get_me | ✅ Live (OpenID Connect) |
Live tools require your app to be approved for the product and the token to carry the right scope; otherwise LinkedIn returns HTTP 403 (surfaced verbatim).
Setup checklist
Everything below must be in place before a tool call can return LinkedIn data.
Parts 1 and 4 are one-time. linkedin-requirements.html in the repo is an
interactive version of the LinkedIn-side items.
1. LinkedIn side (developer portal)
[ ] A LinkedIn app at https://www.linkedin.com/developers/apps, associated with
a Company Page. Its **Client ID** and **Client Secret** are on the **Auth** tab.[ ] The products you need, requested on the Products tab (table below). Only
the first is self-serve; the other two go through LinkedIn review.[ ] Redirect URLs on the Auth tab, matching
LINKEDIN_REDIRECT_URIcharacterfor character: `http://localhost:5555/callback` for the CLI bootstrap, and `https://<host>/admin/linkedin/callback` for the browser-only bootstrap on a deployed server. LinkedIn allows plain HTTP for localhost only.[ ] The LinkedIn account you will authorize with is an admin of the company page
(organization tools) and/or has access to the ad account (ad tools).
| Product | Approval | Unlocks |
| ------------------------------------------ | ------------------------------------ | ---------------------------- |
| Sign In with LinkedIn using OpenID Connect | self-serve, immediate | linkedin_get_me |
| Community Management API | LinkedIn review; requires page admin | organization tools |
| Marketing Developer Platform | LinkedIn review | ad tools, and refresh tokens |
Without a product that grants refresh tokens, access tokens expire every ~60 days and the authorization has to be repeated by hand.
2. Machine side
[ ] Node.js 18+ (developed on Node 24) and
npm install.[ ]
npm run buildif anything will start fromdist/:npm start,`npm run start:http`, process managers, MCP clients. The `dev` scripts run from source and need no build.[ ] A writable location for the token file. The default is
.linkedin-token.jsonin the working directory; set an absolute `TOKEN_FILE` when an MCP client or a process manager launches the server from somewhere else.[ ] For remote callers: a TLS-terminating reverse proxy in front,
`HTTP_HOST=0.0.0.0`, and the port reachable only through that proxy.
3. Configuration (.env or injected environment)
Copy .env.example to .env and fill in:
[ ]
LINKEDIN_CLIENT_IDandLINKEDIN_CLIENT_SECRET(also used for refresh).[ ]
LINKEDIN_REDIRECT_URIequal to a registered redirect URL.[ ]
LINKEDIN_SCOPESlisting only scopes whose product is approved (mapping underConfiguration). One unapproved scope makes LinkedIn refuse the whole authorization.[ ]
LINKEDIN_API_VERSIONset to an active month (default202608). An HTTP 426`NONEXISTENT_VERSION` error means it needs bumping.[ ]
TOKEN_ENC_KEY, generated withnpm run gen:secret -- --key TOKEN_ENC_KEY --write,so the token file is encrypted at rest (see **Generating secrets**).[ ]
MCP_API_TOKEN, generated withnpm run gen:secret -- --write. HTTP transportonly; the HTTP server refuses to start without it (see **Generating secrets**).[ ]
HTTP_HOST/HTTP_PORT/MCP_PATHif the defaults0.0.0.0:3000/mcparenot right. Use `127.0.0.1` for a machine-local dev server.
.env is read only from the process working directory. Anything that starts the
server from elsewhere must inject these as real environment variables.
4. One-time authorization
[ ] Run
npm run auth, or use the admin route (next section), and approve in abrowser with the account from part 1.[ ]
GET /healthreportsauthorized: true. IfhasRefreshTokenisfalse, theapp is not yet approved for refresh tokens and you will repeat this step every ~60 days until it is.
5. Verify
[ ]
npm run build && npm run smoke && npm run smoke:httpchecks the wiring andneeds no LinkedIn token.[ ]
npm run inspector, then calllinkedin_get_me, is the first real round trip.[ ] Every other tool returns HTTP 403 until its product is approved and you have
re-authorized with the new scopes. That is expected, not a fault.
Configuration
All configuration is via environment variables (see .env.example). Key ones:
| Variable | Purpose |
| ----------------------------------------------- | ----------------------------------------------------------------------------------------- |
| LINKEDIN_CLIENT_ID / LINKEDIN_CLIENT_SECRET | LinkedIn app credentials (also used to refresh) |
| LINKEDIN_REDIRECT_URI | Must exactly match a registered redirect URL |
| AUTH_LISTEN_PORT | Local port for the npm run auth callback listener; defaults to the redirect URI's port |
| LINKEDIN_SCOPES | Space-separated scopes to request at authorization |
| LINKEDIN_API_VERSION | LinkedIn-Version header (YYYYMM), default 202608; bump when LinkedIn returns HTTP 426 |
| TOKEN_STORE | file (default) or memory |
| TOKEN_FILE | JSON token file (default .linkedin-token.json, resolved from the cwd) |
| TOKEN_ENC_KEY | 32-byte key (hex/base64) to encrypt the token file at rest (recommended) |
| MCP_API_TOKEN | Required for HTTP — shared bearer secret callers must send |
| HTTP_HOST / HTTP_PORT / MCP_PATH | HTTP bind + endpoint path (default 0.0.0.0:3000/mcp) |
| ALLOWED_ORIGINS / ALLOWED_HOSTS | Optional CSV allowlists (DNS-rebinding protection) |
Scope → tool mapping:
| Scope | Enables |
| -------------------------- | --------------------------------------- |
| openid profile email | linkedin_get_me |
| r_organization_social | organization statistics tools |
| r_ads, r_ads_reporting | ad account / campaign / analytics tools |
Generating secrets
Two values in .env are secrets you create yourself. Neither comes from LinkedIn.
MCP_API_TOKENis the shared bearer secret between this server and its callers. The HTTP server reads it from the environment, every client sends it asAuthorization: Bearer <value>, and the two are compared in constant time. It protects your server, and by extension your LinkedIn token, from anyone else on the network. Stdio does not use it.TOKEN_ENC_KEYis the 32-byte key that encrypts the token file at rest.
scripts/gen-secret.mjs produces both from Node's cryptographic random generator
as hex, which is safe in headers, shell quoting and .env files. The default is
32 bytes, or 256 bits.
npm run gen:secret # print a fresh MCP_API_TOKEN value
npm run gen:secret -- --write # write it into ./.env (replace or add the line)
npm run gen:secret -- --key TOKEN_ENC_KEY --write # same for the encryption key
npm run gen:secret --silent -- --env # print MCP_API_TOKEN=<value> for piping
npm run gen:secret -- --bytes 48 # a longer secret
node scripts/gen-secret.mjs --help # all options--write changes only the one line, leaves the rest of .env untouched, writes
atomically, and creates .env if it does not exist. Use --silent or call the
script through node directly when piping, because npm otherwise prints its own
banner ahead of the value.
Without the script, any long random string works, for example:
node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"
openssl rand -hex 32Rotating. Change MCP_API_TOKEN whenever you like: run the --write form,
restart the server, and update every client. There is nothing to revoke elsewhere.
TOKEN_ENC_KEY is different: the token file is encrypted with the current key, so
a new key makes it unreadable. The script refuses to rotate it while a token file
exists; delete the token file and run npm run auth again, or pass --force if
you really mean it.
One-time authorization (bootstrap)
Authorize once; the server then auto-refreshes until the refresh token expires (~365 days), at which point you re-run this once.
Production (browser, no shell on the box): set env (MCP_API_TOKEN, and
LINKEDIN_REDIRECT_URI=https://<host>/admin/linkedin/callback registered on the
LinkedIn app), start the server, then:
# Ask the server for an authorize URL (bearer-protected):
curl -H "Authorization: Bearer $MCP_API_TOKEN" https://<host>/admin/linkedin/login
# Open the returned authorizeUrl in a browser and approve.
# LinkedIn redirects to /admin/linkedin/callback, which writes the token to the store.CLI alternative: npm run auth runs the flow locally and writes the token to
the token file. It also prints the token lines for manual use. It listens for the
redirect on AUTH_LISTEN_PORT if set, otherwise on the port in the redirect URI.
CLI through a tunnel: when the approving browser is on another device, expose the listener with ngrok and make the tunnel address the redirect:
ngrok http 5555
# In .env: LINKEDIN_REDIRECT_URI=https://<subdomain>.ngrok-free.app/callback
# AUTH_LISTEN_PORT=5555
# Register that same https URL on the app's Auth tab, then:
npm run authWithout AUTH_LISTEN_PORT the script would infer port 443 from the HTTPS URL.
The free ngrok subdomain changes on every restart and shows a click-through
warning page before the callback; both are harmless, just re-register the URL.
Ways to start the server
There are two transports, and each can run from source (with reload) or from the
compiled dist/. Whatever starts the process, it needs the same environment:
either a working directory containing .env, or the variables injected directly.
| Command | Transport | Runs | Use it for |
| ------------------------------------------ | --------- | ------------------------------------ | -------------------------------------------------------- |
| npm run dev | stdio | source, reloads on change | developing the tools locally |
| npm run dev:http | HTTP | source, reloads on change | developing the HTTP service locally |
| npm start | stdio | dist/index.js | stdio without a TypeScript toolchain |
| npm run start:http | HTTP | dist/http.js | production; what remote callers connect to |
| node dist/index.js / node dist/http.js | either | dist/ directly | process managers and MCP client configs, no npm involved |
| npm run inspector | stdio | dist/index.js inside MCP Inspector | interactive testing in a browser |
| bi-linkedin-mcp | stdio | the package binary | after npm link or a global install, from any directory |
Stdio starts do not need MCP_API_TOKEN; HTTP starts refuse to run without it.
Started by an MCP client over stdio
The client spawns the server as a child process from its own working directory,
so .env is not read. Point it at the compiled entry point and pass the
environment. Authorize once with npm run auth from the repo root first; the
client then reads the same token file.
Claude Desktop, in claude_desktop_config.json:
{
"mcpServers": {
"linkedin": {
"command": "node",
"args": ["<repo>/dist/index.js"],
"env": {
"LINKEDIN_CLIENT_ID": "...",
"LINKEDIN_CLIENT_SECRET": "...",
"TOKEN_FILE": "<repo>/.linkedin-token.json",
"TOKEN_ENC_KEY": "..."
}
}
}
}Claude Code, stdio:
claude mcp add linkedin \
-e LINKEDIN_CLIENT_ID=... -e LINKEDIN_CLIENT_SECRET=... \
-e TOKEN_FILE=<repo>/.linkedin-token.json -e TOKEN_ENC_KEY=... \
-- node <repo>/dist/index.jsStarted as a long-running HTTP service
Run node dist/http.js under any process manager (pm2, a systemd unit, NSSM or
Task Scheduler on Windows) with the environment injected, a restart-on-failure
policy, and a TLS-terminating proxy in front. GET /health is the liveness probe
and also reports whether LinkedIn authorization is still valid. Remote MCP clients
connect as described in the next section.
Started by hand for a quick check
From the repo root, where .env is picked up automatically:
npm run build
npm run start:http # or: npm run dev:http
curl http://127.0.0.1:3000/healthnpm run smoke and npm run smoke:http start and stop a throwaway instance on
their own and need neither a token nor a token file.
Connecting your production project (the MCP client)
The server speaks Streamable HTTP at https://<host><MCP_PATH> and requires
Authorization: Bearer <MCP_API_TOKEN> on every request.
Claude Code:
claude mcp add --transport http linkedin https://<host>/mcp \
--header "Authorization: Bearer <MCP_API_TOKEN>"Any MCP client SDK: point its Streamable HTTP transport at the URL and add the
Authorization header. Your client library handles the session handshake
(Mcp-Session-Id) automatically.
Token lifecycle
- LinkedIn access tokens last ~60 days; refresh tokens ~365 days.
- The server refreshes the access token proactively (before expiry) and reactively (on a 401), persisting the new token to the store. Concurrent requests share one refresh.
- When the refresh token itself expires (or is revoked), tools return a clear
"re-authorization required" error and
GET /healthreportsauthorized: false. Re-run the bootstrap. Consider alerting on that health field.
Security
- TLS: terminate at your reverse proxy / load balancer; the app serves plain HTTP behind it.
- Bearer secret:
MCP_API_TOKENis a shared secret you invent, compared in constant time.npm run gen:secretprints a fresh 256-bit one; add--writeto put it straight into.env. Rotate by updating it on the server and every client. - Tokens at rest: set
TOKEN_ENC_KEYto encrypt access/refresh tokens (AES-256-GCM) in the token file. - Binding / rebinding: for server-to-server callers no
Originis sent and the bearer token is the gate. If browser clients will connect, setALLOWED_ORIGINS/ALLOWED_HOSTSto enable DNS-rebinding protection. - Never commit
.envor the token file (the defaults are covered by.gitignore; a customTOKEN_FILEinside the repo is not).
Health
GET /health (unauthenticated, no secrets) returns { status, linkedin: {
authorized, accessTokenExpiresAt, refreshTokenExpiresAt, hasRefreshToken } } for
load-balancer probes and re-auth alerting.
