LinkIntel · Internal

Private LinkedIn Analytics MCP

A read-only MCP server that gives an agent your own LinkedIn performance data. Add it by URL with a bearer token: no repository clone, no local process, nothing to keep running. It uses only LinkedIn’s official Member Post Analytics API, so there is no scraping, no cookies and no browser extension.

POST https://www.getlinkintel.com/api/mcp
Authorization: Bearer lnki_...
Transport
HTTP
Auth
Bearer token
Tools
13
Refresh
Daily, 06:15 UTC
History
90 days

Install

Three steps. If you signed up through the site, the first is already done and the second is offered to you as a block you can hand straight to your assistant.

The short way

Subscribers get their token automatically when they connect LinkedIn, and /setup shows it once alongside a ready-made instruction block to paste into Claude. The manual steps below are for operators minting tokens by hand.

  1. 1

    Get a token

    Tokens map to exactly one LinkedIn profile and start with lnki_. Signing up issues one for you. To mint one by hand instead, use the admin API, authenticating with the Supabase service role key:

    $ curl -s -X POST https://www.getlinkintel.com/api/mcp/tokens     -H "Authorization: Bearer $SUPABASE_SERVICE_ROLE_KEY"     -H "Content-Type: application/json"     -d '{"label":"hermes"}'

    The response carries the token once. Only a hash is stored, so it cannot be shown again. Put it in a password manager now.

    {
      "token": "lnki_...",
      "label": "hermes",
      "profile_id": "...",
      "note": "Store this now. Only a hash is kept, so it cannot be shown again."
    }

    See Managing tokens for listing and revoking. There is also a CLI, if you have the repository checked out:

    $ pnpm --filter @linkintel/private-linkedin-mcp token:new --label="hermes"
  2. 2

    Add the server

    For Claude Code:

    $ claude mcp add --transport http linkintel https://www.getlinkintel.com/api/mcp     --header "Authorization: Bearer $LINKINTEL_TOKEN"
    
    $ claude mcp list | grep linkintel   # expect: Connected

    For Claude Desktop and other clients that read a JSON config:

    {
      "mcpServers": {
        "linkintel": {
          "type": "http",
          "url": "https://www.getlinkintel.com/api/mcp",
          "headers": { "Authorization": "Bearer lnki_..." }
        }
      }
    }

    Prefer an environment variable over pasting the token into a config file that may end up in a backup or a screen share.

  3. 3

    Confirm it works

    Ask the agent to call get_data_status. A healthy response looks like this:

    {
      "connected": true,
      "connection_status": "active",
      "data": { "total_days_stored": 90, "status": "fresh", "days_stale": 0 },
      "api_budget_today": { "used": 0, "limit": 75, "remaining": 75 }
    }

    To check without an agent, the endpoint speaks plain JSON-RPC over POST:

    $ curl -s -X POST https://www.getlinkintel.com/api/mcp     -H "Authorization: Bearer $LINKINTEL_TOKEN"     -H "Content-Type: application/json"     -H "Accept: application/json, text/event-stream"     -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

Tools

Output is compact JSON with totals, baselines, deltas and prior-period comparisons already calculated, so the agent reports numbers rather than doing its own arithmetic.

ToolPurpose
get_data_status()Connection health, data freshness, token expiry, remaining request budget. Reach for this first whenever a number looks wrong.
sync_now(days)Fetch the latest metrics on demand. days backfills that many days.
get_performance_summary(period)Totals, daily series, baseline and previous-period comparison. 7d, 14d, 30d or 90d.
get_daily_trend(metric, days)One metric as a time series with deltas against its baseline.
detect_anomalies(days)Days meaningfully above or below the rolling baseline.
get_post_performance(post_url)Official lifetime metrics for one pasted post URL.
compare_periods(a, b)Two explicit date ranges, side by side. End dates are exclusive.
log_post(post_url, text, theme)Register a post and its metrics into the content corpus.
get_top_posts(metric, limit)Rank registered posts. Only knows posts added with log_post, because LinkedIn cannot list your posts.
get_content_insights()Registered posts summarised by theme.
import_posts(posts)Register many posts at once from share URNs you already hold. Costs no LinkedIn budget.
import_from_typefully(api_key, social_set_id)Pull every published LinkedIn post out of a Typefully account. Omit social_set_id to list the sets first. Connecting Typefully at /setup instead stores the key, so imports can be repeated as you publish.
backfill_post_metrics(days)Measure registered posts that have no metrics yet. 3 requests per post, so it is the expensive one.

What it cannot do: find your posts

LinkedIn’s official API can measure any post you name, but it cannot list which posts exist. Enumerating a member’s own posts requires the r_member_social permission, which LinkedIn has closed and is not accepting requests for. So get_top_posts and get_content_insights cover only posts registered with log_post.

Account-level analytics are unaffected: all 90 stored days work without registering anything. detect_anomalies is usually the better route to “what worked”, since a standout day points straight at the post responsible.

To fill the corpus in bulk, connect a source at /setup. Typefully is the most complete route because it records every post you have published. Failing that, the 90-day export from LinkedIn’s creator analytics downloads immediately and its post URLs carry the URN type in the slug (-share- or -ugcPost-), which is exactly what the analytics API measures. The full data archive is the weakest option: it takes days and its URLs often carry only an activity id, which cannot be resolved, so those rows are counted and reported rather than registered as posts that could never be measured.

Try it

“How did my LinkedIn do in the last two weeks, and which day spiked?”

The agent should call get_performance_summary and detect_anomalies and name the day. With 90 days stored, detect_anomalies(days=90) surfaces the late-June spike that peaked at 16,894 impressions on 2026-06-26 against a baseline near 1,500.

Trusting the numbers

An empty window and a quiet fortnight both produce zeros. Every metric-returning tool carries a freshness block so the agent can tell them apart.

StatusMeaningWhat to do
freshCurrent and complete. At most one day behind, which is normal between UTC midnight and the morning sync.Nothing.
incompleteCurrent, but the window has holes, so totals understate reality.sync_now with the window length.
staleMore than one day behind. The scheduled sync is not keeping up.Check get_data_status, then sync_now.
emptyNo stored metrics at all. The zeros are not measurements.sync_now(days=90).

The block also reports days_stale, how much of the requested window actually has rows, and a plain-language warning when something is off.

Tools degrade rather than fail

Period members_reached needs a live LinkedIn call, because unique reach is only correct as a single official TOTAL window and can never be summed from daily figures. When that call is unavailable, the tool still returns every stored metric and sets reach to null with a reach_unavailable note. A null never becomes a zero, and a missing value is never compared against a real one to manufacture a delta.

"reach_unavailable": "LinkedIn safety budget reached (75 member requests today)...",
"metrics": {
  "impressions":     { "value": 10640, "previous": 9701, "delta": 939,  "percent_change": 9.7 },
  "members_reached": { "value": null,  "previous": null, "delta": null, "percent_change": null }
}

How syncing works

The sync runs automatically every day at 06:15 UTC. Nothing needs to be typed for the data to stay current, and your laptop does not need to be on.

The logic lives in exactly one place, a scheduled Supabase edge function. Three callers invoke that same function, so there is no second copy to drift out of step.

CallerPurpose
Scheduled nightly jobThree-day self-healing sync
sync_nowOn-demand refresh from inside a conversation
pnpm syncOperator CLI, for backfills and debugging

As a client you only ever need sync_now. The nightly job keeps things current on its own, and the CLI is for whoever runs the server.

Backfills are resumable

  • Per-day windows are fetched newest first, so an interrupted backfill leaves the recent days complete.
  • Days that already hold a value are skipped, so re-running only pays for what is still missing.
  • If the budget runs out mid-run, everything already fetched is saved and the tool returns status: "partial". Nothing is thrown away.
  • Rows where nothing was measured are never written, since their nulls would sum to zero and read as a genuine day of no activity.

Request budget

Every outbound call, including 429 retries, is atomically reserved against a per-profile cap before it reaches LinkedIn.

Safety cap
75 / UTC day
LinkedIn limit
100 / member / day
Headroom
25 requests
3-day sync
~13 requests

Cost is asymmetric, and this drives the whole backfill design:

Metric groupCost3 days90 days
The seven DAILY metrics7 requests for any window size77
LINK_CLICKS1 request per day390
MEMBERS_REACHED1 request per day, skipped above 7 days30

Because a 90-day pull of the core metrics costs only seven requests, long backfills are cheap for everything that matters. Windows longer than seven days skip the daily MEMBERS_REACHED series deliberately: it is non-additive and is never summed into a period total, so it is a diagnostic not worth one request per day.

What repeated use actually costs

Most tools read only from storage and are free to call as often as you like. The two that need a live answer are cached, so asking the same question twice in a day costs nothing the second time.

ToolFirst callRepeat
get_data_status, get_daily_trend, detect_anomalies, get_content_insights00
get_performance_summary, compare_periods20 (until UTC midnight)
get_post_performance, log_post50 (for 1 hour)
sync_now7 to 97skips days already stored

A period’s unique reach cannot change once the window has closed, so it is cached until the date rolls over. Post metrics keep accruing, so they use a one-hour clock instead: a post published minutes ago is never reported as an hour stale. Responses carry reach_from_cache and from_cache so you can see which happened.

When the cap is reached

Requests are blocked locally before they reach LinkedIn, and the budget resets at UTC midnight. Stored metrics keep working throughout; only live calls such as period reach are affected.

Tokens

One token maps to exactly one LinkedIn profile. It is the only thing standing between a request and someone's analytics, so it is treated accordingly.

  • Only a SHA-256 hash is stored. A database leak yields no working credentials, and the raw token exists exactly once, at the moment it is issued.
  • Unknown, malformed and revoked tokens all return the same 401. Nothing distinguishes them, so the endpoint cannot be used to discover which tokens are real.
  • Revocation applies on the very next request. There is no cache to wait out.
  • Every use updates a last-seen timestamp, so a token that is no longer in use is easy to spot and retire.
  • Tokens are not passwords and grant no access to your LinkedIn account. They only read analytics LinkedIn has already published to the profile owner.

Managing tokens

All three operations live at https://www.getlinkintel.com/api/mcp/tokens and take the Supabase service role key as a bearer. That is the right privilege level rather than a new one: anyone holding that key can already write to the tokens table directly, so this endpoint is convenience, not an escalation.

MethodDoesBody
POSTCreate. Returns the raw token once, with 201.{"label","profile_id?"}
GETList. Metadata only; hashes are never selected.none
DELETERevoke. Effective immediately.{"label"} or {"id"}

profile_id is optional while exactly one profile exists. Add a second and it becomes required, so a token can never silently land on the wrong profile.

$ curl -s https://www.getlinkintel.com/api/mcp/tokens     -H "Authorization: Bearer $SUPABASE_SERVICE_ROLE_KEY"

$ curl -s -X DELETE https://www.getlinkintel.com/api/mcp/tokens     -H "Authorization: Bearer $SUPABASE_SERVICE_ROLE_KEY"     -H "Content-Type: application/json"     -d '{"label":"hermes"}'

If a token leaks

Say so and it will be revoked, which takes effect immediately. Nothing else needs to be rotated: the token grants read access to one profile’s analytics and nothing more. Replacing it is a one-line command.

Troubleshooting

SymptomCause and fix
401 A valid bearer token is requiredThe token is missing, mistyped, or has been revoked. All three return the same error on purpose, so nobody can probe for which tokens exist. Check the header is Authorization: Bearer lnki_... and ask for a fresh token if unsure.
405 This endpoint accepts POST onlySomething issued a GET, usually a browser. The endpoint is JSON-RPC over POST and has nothing to show in a browser tab.
connected: falseThe profile behind your token has no LinkedIn authorisation yet. That is a one-time server-side OAuth step, not something you can fix from the client.
LinkedIn safety budget reachedExpected once 75 requests are spent in a UTC day. Stored metrics still work and live reach returns null. Resets at UTC midnight.
Tools return zerosCheck get_data_status. A status of empty means no data, not no activity.
Failed to connect in claude mcp listConfirm the URL is exactly the endpoint above and the Authorization header actually reached the client. Then try the curl in step 3, which shows the raw error.

Limits

Known constraints, stated plainly, so nobody rediscovers them the hard way.

  • LinkedIn reports roughly a one-day lag, and says aggregate reshares, reactions and comments can differ from its own UI.
  • Period members_reached is fetched as a single official TOTAL window at tool time. Daily reach windows are kept only as a non-additive diagnostic and are never summed into period reach.
  • Days backfilled in a window longer than seven days have no daily members_reached value by design. Period reach is unaffected.
  • FOLLOWER_GAINED_FROM_CONTENT and PROFILE_VIEW_FROM_CONTENT currently return LinkedIn 500s. They are reported as non-blocking warnings and probed weekly, not on every sync.
  • Per-post calls accept normal activity URLs and raw ugcPost or share URNs. Activity IDs are tried as ugcPost first and share on a 404. Posts are never enumerated or scraped.
  • There is no official way to enumerate historical post URNs, which is why log_post exists: the content corpus is built from posts an agent explicitly registers.

The ceiling on going multi-user

LinkedIn’s Development tier allows 500 requests per day across the whole app. At roughly 13 requests per profile per day, that caps the product at about 35 to 40 connected profiles. Getting past it needs a LinkedIn tier increase, not a code change.

Read-only, official API only. Source lives in apps/mcp-server. Deployment and credential runbook steps are kept in the repository README rather than here.