A paid, read-only MCP server for the selected public X references in your content brief. Add it by URL with a bearer token: no repository clone, no local process and nothing to keep running. prepare_content_brief reads stored evidence only; it never searches X or makes a paid X request. Legacy LinkedIn analytics tools remain optional for existing customers.
Want the agent-ready quick start? Give your assistant the public LinkIntel skill.
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.
Paid subscribers get their token after Polar entitlement is confirmed, 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 LinkIntel 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 prepare_content_brief with a topic and cohort you saved in setup. A healthy response includes only stored, cited evidence:
{
"topic": "launch lessons",
"cohort": "developer tools",
"reference_posts": [{ "citation": { "platform": "x", "url": "https://x.com/..." } }],
"limitations": ["Stored public-reference evidence only; it is not a reach prediction."]
}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"}'The X-first tool is prepare_content_brief. The LinkedIn tools below remain available only when an existing customer has chosen that optional source; owned-X snapshots add one conditional tool when configured.
| Tool | Purpose |
|---|---|
get_data_status() | Available platforms and connection state, then LinkedIn freshness, token expiry and request budget. Reach for this first whenever a number looks wrong. |
sync_now(days) | Report the scheduled-only refresh policy. It never starts a LinkedIn sync. |
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) | Stored lifetime metrics for one registered post. |
compare_periods(a, b) | Two explicit date ranges, side by side. End dates are exclusive. |
log_post(post_url, text, theme) | Register a post for the scheduled measurement queue. |
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. |
prepare_content_brief(topic, cohort) | The X-first tool: a cached brief from the signed-in customer’s selected public X reference cohort, with citations, freshness and limitations. It defaults to X references; include legacy LinkedIn metrics only for a clear LinkedIn request. |
get_owned_x_performance(limit) | Only listed when owned-X collection is configured. Reads stored owner-account snapshots only; it never fetches X or joins a public-reference brief. |
import_posts(posts) | Register many posts at once from share URNs you already hold. Costs no LinkedIn budget. |
import_from_typefully(api_key, account_id) | Pull every published LinkedIn post out of a Typefully account. Omit account_id to list the social sets first. Connecting Typefully at /setup instead stores the key, so imports can be repeated as you publish. |
import_from_postiz(api_key, account_id, base_url) | Pull every published LinkedIn post out of a Postiz account, cloud or self-hosted. Omit account_id to list the channels first. Pass base_url for a self-hosted instance. Connecting Postiz at /setup instead stores the key. |
backfill_post_metrics(days) | Report registered posts waiting for scheduled measurement. It does not call LinkedIn. |
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 and Postiz are the most complete routes because they record every post they published, along with the LinkedIn share URN needed to measure it. Publishing through something else? POST /api/ingest/posts takes a post URL from any tool that can make an HTTP request, authorised by a webhook secret that can register posts and nothing else. See the integrations. 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.
“What does my saved X reference brief support about this topic, and what is still a guess?”
The agent should call prepare_content_brief with the exact saved topic and cohort, then separate cited observations from hypotheses it suggests testing. It must not search X, import another post, or present reference performance as the customer’s own baseline.
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. | Wait for the scheduled sync. |
| stale | More than one day behind. The scheduled sync is not keeping up. | Check get_data_status and the next scheduled sync. |
| empty | No stored metrics at all. The zeros are not measurements. | Wait for the scheduled sync. |
The block also reports days_stale, how much of the requested window actually has rows, and a plain-language warning when something is off.
Unique period members_reached is not stored for arbitrary date windows, because it can never be summed from daily figures. The tool 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": "Official unique period reach is not stored for arbitrary date windows...",
"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 scheduled jobs. MCP requests only read the stored results, so a conversation can never consume the shared LinkedIn allowance.
| Caller | Purpose |
|---|---|
| Scheduled nightly job | Three-day self-healing sync |
The nightly job keeps stored data current. sync_now reports that policy without triggering a refresh.
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.
Every MCP tool reads or writes LinkIntel storage only, so users can ask questions as often as they like without spending LinkedIn quota.
| Tool | First call | Repeat |
|---|---|---|
get_data_status, get_daily_trend, detect_anomalies, get_content_insights | 0 | 0 |
get_performance_summary, compare_periods | 0 | 0 |
get_post_performance, log_post | 0 | 0 |
sync_now | 0 | reports scheduled refresh policy |
Scheduled jobs persist the metrics they collect. A post published minutes ago may stay pending until the next measurement run, and unique reach remains unavailable for arbitrary period windows rather than triggering a live API call.
Scheduled requests are blocked locally before they reach LinkedIn, and the budget resets at UTC midnight. Stored metrics keep working throughout.
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 for a legacy analytics tool | The profile behind your token has not added the optional LinkedIn source. X-reference briefs do not require LinkedIn authorisation. |
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.