npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@cashtokenai/bi-linkedin-mcp

v0.1.0

Published

Model Context Protocol server exposing LinkedIn business-intelligence tools via the official LinkedIn API.

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_URI character

    for 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 build if anything will start from dist/: 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.json

    in 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_ID and LINKEDIN_CLIENT_SECRET (also used for refresh).

  • [ ] LINKEDIN_REDIRECT_URI equal to a registered redirect URL.

  • [ ] LINKEDIN_SCOPES listing only scopes whose product is approved (mapping under

    Configuration). One unapproved scope makes LinkedIn refuse the whole authorization.
  • [ ] LINKEDIN_API_VERSION set to an active month (default 202608). An HTTP 426

    `NONEXISTENT_VERSION` error means it needs bumping.
  • [ ] TOKEN_ENC_KEY, generated with npm run gen:secret -- --key TOKEN_ENC_KEY --write,

    so the token file is encrypted at rest (see **Generating secrets**).
  • [ ] MCP_API_TOKEN, generated with npm run gen:secret -- --write. HTTP transport

    only; the HTTP server refuses to start without it (see **Generating secrets**).
  • [ ] HTTP_HOST / HTTP_PORT / MCP_PATH if the defaults 0.0.0.0:3000/mcp are

    not 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 a

    browser with the account from part 1.
  • [ ] GET /health reports authorized: true. If hasRefreshToken is false, the

    app 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:http checks the wiring and

    needs no LinkedIn token.
  • [ ] npm run inspector, then call linkedin_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_TOKEN is the shared bearer secret between this server and its callers. The HTTP server reads it from the environment, every client sends it as Authorization: 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_KEY is 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 32

Rotating. 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 auth

Without 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.js

Started 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/health

npm 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 /health reports authorized: 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_TOKEN is a shared secret you invent, compared in constant time. npm run gen:secret prints a fresh 256-bit one; add --write to put it straight into .env. Rotate by updating it on the server and every client.
  • Tokens at rest: set TOKEN_ENC_KEY to encrypt access/refresh tokens (AES-256-GCM) in the token file.
  • Binding / rebinding: for server-to-server callers no Origin is sent and the bearer token is the gate. If browser clients will connect, set ALLOWED_ORIGINS/ALLOWED_HOSTS to enable DNS-rebinding protection.
  • Never commit .env or the token file (the defaults are covered by .gitignore; a custom TOKEN_FILE inside 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.