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_...
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.
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.
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"
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.
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"}'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.
| Tool | Purpose |
|---|---|
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. |
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.
“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.
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.
| Status | Meaning | What to do |
|---|---|---|
| fresh | Current and complete. At most one day behind, which is normal between UTC midnight and the morning sync. | Nothing. |
| incomplete | Current, but the window has holes, so totals understate reality. | sync_now with the window length. |
| stale | More than one day behind. The scheduled sync is not keeping up. | Check get_data_status, then sync_now. |
| empty | No 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.
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 }
}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.
| Caller | Purpose |
|---|---|
| Scheduled nightly job | Three-day self-healing sync |
sync_now | On-demand refresh from inside a conversation |
pnpm sync | Operator 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.
status: "partial". Nothing is thrown away.Every outbound call, including 429 retries, is atomically reserved against a per-profile cap before it reaches LinkedIn.
Cost is asymmetric, and this drives the whole backfill design:
| Metric group | Cost | 3 days | 90 days |
|---|---|---|---|
| The seven DAILY metrics | 7 requests for any window size | 7 | 7 |
LINK_CLICKS | 1 request per day | 3 | 90 |
MEMBERS_REACHED | 1 request per day, skipped above 7 days | 3 | 0 |
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.
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.
| Tool | First call | Repeat |
|---|---|---|
get_data_status, get_daily_trend, detect_anomalies, get_content_insights | 0 | 0 |
get_performance_summary, compare_periods | 2 | 0 (until UTC midnight) |
get_post_performance, log_post | 5 | 0 (for 1 hour) |
sync_now | 7 to 97 | skips 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.
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.
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.
401. Nothing distinguishes them, so the endpoint cannot be used to discover which tokens are real.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.
| Method | Does | Body |
|---|---|---|
POST | Create. Returns the raw token once, with 201. | {"label","profile_id?"} |
GET | List. Metadata only; hashes are never selected. | none |
DELETE | Revoke. 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"}'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.
| Symptom | Cause and fix |
|---|---|
401 A valid bearer token is required | The 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 only | Something issued a GET, usually a browser. The endpoint is JSON-RPC over POST and has nothing to show in a browser tab. |
connected: false | The 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 reached | Expected once 75 requests are spent in a UTC day. Stored metrics still work and live reach returns null. Resets at UTC midnight. |
Tools return zeros | Check get_data_status. A status of empty means no data, not no activity. |
Failed to connect in claude mcp list | Confirm 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. |
Known constraints, stated plainly, so nobody rediscovers them the hard way.
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.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.ugcPost or share URNs. Activity IDs are tried as ugcPost first and share on a 404. Posts are never enumerated or scraped.log_post exists: the content corpus is built from posts an agent explicitly registers.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.