linkedin-mcp
Your LinkedIn profile, one prompt away.
A single Go binary that puts the LinkedIn REST API in front of any MCP client, for you and for the people you invite.
Ask your assistant "draft a post about what I shipped this week", "how did Tuesday's post land?", "answer the last three comments in my voice" and get an answer from the real account, then publish back to it.
> Écris un post sur le MCP que je viens de sortir, puis dis-moi comment le précédent a marché.
connection_status -> publier: oui, statistiques: oui, expire dans 58 jours
publish_post -> APERÇU (rien n'est parti)
« J'ai passé le week-end à brancher LinkedIn sur MCP... »
Visibilité PUBLIC, 782 caractères
> oui, publie
publish_post -> urn:li:share:7269...
post_analytics -> publication précédente, 30 derniers jours
Impressions 4 812
Comptes atteints 3 190
Réactions 147 (3,1 %)
Abonnés gagnés 23
This is a remote, multi-tenant MCP server. You host it once; each person connects their own LinkedIn account and only ever sees their own posts.
Why
The LinkedIn MCP servers out there are either scrapers, or single-user wrappers around one hard-coded access token. This one is a real server: your friends get a URL, they click Connect, and their assistant is talking to their own profile.
- Multi-tenant by construction. The tenant comes from the verified JWT, never from
a tool parameter, and no store method resolves a post without a
tenant_id. Isolation is structural, not defensive. - Its own OAuth 2.1 server. Dynamic client registration, mandatory PKCE S256, rotating refresh tokens, HS256 access tokens. Any conforming MCP client connects itself, with no manual token handling.
- Fail-closed writes. Nothing is published, edited or deleted without
confirm=true. Without it, the tool returns a preview and never calls LinkedIn. - It works on day one. LinkedIn keeps reading a member's own posts behind a
restricted permission. The server therefore keeps its own ledger of everything it
published, so
list_postsanswers immediately, and says out loud where the rows come from. The day the permission is granted, the same tool reads LinkedIn instead. - One static binary.
CGO_ENABLED=0, adistrolessimage, a SQLite file on a volume. Two dependencies ingo.mod, both justified there.
How it works
MCP client linkedin-mcp LinkedIn
(Claude, ...) (your VPS, one binary)
| 1. POST /mcp (no token) | |
|---------------------------------->| |
| 401 + WWW-Authenticate | |
|<----------------------------------| |
| | |
| 2. POST /oauth/register (DCR) | |
| 3. GET /oauth/authorize (PKCE) | |
|---------------------------------->| |
| | 302 to LinkedIn |
| |----------------------->|
| | member signs in |
| |<-----------------------|
| | GET /linkedin/callback|
| | code -> access token |
| 302 back with an auth code | |
|<----------------------------------| |
| 4. POST /oauth/token (PKCE) | |
|---------------------------------->| |
| access_token (JWT, tenant inside)| |
|<----------------------------------| |
| | |
| 5. tools/call + Bearer | member token from DB |
|---------------------------------->|----------------------->|
Two OAuth flows, deliberately kept apart:
- the MCP client authenticates against this server (OAuth 2.1 + PKCE + DCR);
- this server authenticates against LinkedIn (OAuth 2.0 authorization code).
The MCP client never sees a LinkedIn token. The JWT it receives carries the tenant id as its subject, and every tool reads that subject, never an argument.
Layers
cmd/linkedin-mcp composition root, nothing else
|
+-- adapters/ linkedin, sqlite, authserver, mcpserver, httpserver, crypto, clock
|
+-- app/ use cases: login, posts, engagement, analytics
|
+-- domain/ entities and ports, standard library only
Adapters never import each other. Dependencies point inwards, and the domain knows nothing about HTTP, SQL, or LinkedIn.
Set up the LinkedIn app
LinkedIn is stricter than Meta: an app must belong to a LinkedIn Page, and that page must be verified by one of its admins before the app can request anything.
-
Create the app. linkedin.com/developers/apps/new. Name it, attach the LinkedIn Page you administer, upload a logo, accept the terms.
-
Verify the app. Tab Settings > Verify, generate the link, open it as a page admin, confirm. Without this step every product request stays greyed out.
-
Request the products. Tab Products:
- Sign In with LinkedIn using OpenID Connect grants
openid profile email, which is how the server learns who is connecting. Instant. - Share on LinkedIn grants
w_member_social: creating, editing and deleting the member's own posts. Instant, and it is the whole of what a self-serve app can do. Commenting, reacting and reading back engagement are not in it, whatever the consent screen's wording suggests. - Community Management API grants everything else:
r_member_social(list posts, read reactions and comments),w_member_social_feed(comment, react),r_member_postAnalytics(statistics). It goes through a review form and is not granted to everyone. The server runs fine without it, refuses those tools up front with the permission named, and spells the whole thing out inconnection_status.
- Sign In with LinkedIn using OpenID Connect grants
-
Declare the redirect URL. Tab Auth > OAuth 2.0 settings > Redirect URLs:
https://<your-domain>/linkedin/callbackHTTPS is mandatory, the match is exact, and a trailing slash counts as a difference.
-
Copy the credentials. Tab Auth: Client ID and Primary Client Secret.
Keep the Page association in mind: LinkedIn ties the app's rate limits and its review to that page, not to your personal account.
What works on day one
Two self-serve products are granted instantly. Everything else sits behind the Community Management review, and the table below is what the live API actually answers, not what the consent screens imply.
A tool whose permission is missing refuses before calling LinkedIn and names
the permission, instead of relaying a bare 403. connection_status answers the
same question in one call, in plain language.
Configure
Everything is environment variables, no configuration file.
Generate the two keys:
$ openssl rand -base64 32 # TOKEN_CIPHER_KEY
$ openssl rand -base64 32 # JWT_SIGNING_KEY
Losing TOKEN_CIPHER_KEY means every connected member has to reconnect. Losing
JWT_SIGNING_KEY only invalidates the sessions in flight.
Deploy
Docker
$ docker build -t linkedin-mcp .
$ docker run -d --name linkedin-mcp -p 8080:8080 \
-v linkedin-mcp-data:/data \
-e PUBLIC_URL=https://linkedin-mcp.example.com \
-e LINKEDIN_CLIENT_ID=... \
-e LINKEDIN_CLIENT_SECRET=... \
-e TOKEN_CIPHER_KEY=... \
-e JWT_SIGNING_KEY=... \
linkedin-mcp
The image is distroless/static and runs as uid 65532. /data is created in the image
with that ownership, which is what lets Docker initialise a fresh named volume the
process can actually write to.
CapRover
The repository ships a captain-definition, so the app builds as is.
$ git archive --format=tar -o app.tar HEAD
$ curl -sS -X POST \
-H "x-captain-app-token: $APP_TOKEN" \
-H "x-namespace: captain" \
-F "[email protected]" \
"https://captain.example.com/api/v2/user/apps/appData/linkedin-mcp?detached=1"
Three things to know, in the order they usually bite:
- an app token goes in
x-captain-app-token; a session JWT goes inx-captain-auth. The wrong header answers{"status":1106,"description":"Auth token corrupted"}, which looks like a bad token but is a bad header; - CapRover answers HTTP 200 even when it refuses. The verdict is the JSON
statusfield:100is fine,101is deploy started, everything else failed; - add a persistent volume on
/datain the app's dashboard, set the environment variables there, and enable HTTPS. A fresh app has none of that, and a missing variable shows up as a 502 with no other clue.
Then check the deployment actually landed:
$ curl -s https://linkedin-mcp.example.com/healthz
{"status":"ok"}
Behind a reverse proxy
The server speaks plain HTTP and expects TLS to be terminated in front of it. It needs
POST, GET and DELETE on /mcp, and no response buffering: the MCP transport
streams.
Connect a client
Point any MCP client at https://<your-domain>/mcp. The OAuth dance is automatic.
Claude Code
$ claude mcp add --transport http linkedin https://linkedin-mcp.example.com/mcp
Claude Desktop, Cursor, and anything reading mcpServers
{
"mcpServers": {
"linkedin": {
"type": "http",
"url": "https://linkedin-mcp.example.com/mcp"
}
}
}
First call returns a 401 pointing at the authorization server; the client registers itself, opens a browser, and the member signs in with LinkedIn. Nothing to paste.
Inviting someone: send them the same URL. Their tenant is created on their first
sign-in, and neither of you can see the other's posts. Set ALLOWED_MEMBER_IDS if you
want the door closed to everyone else.
Tools
Thirteen tools. Descriptions are in French, because that is the language the assistant is asked to work in; the code and this README are in English.
Read
The analytics tools accept IMPRESSION, MEMBERS_REACHED, RESHARE, REACTION,
COMMENT, POST_SAVE, POST_SEND, LINK_CLICKS, PREMIUM_CTA_CLICKS,
FOLLOWER_GAINED_FROM_CONTENT, PROFILE_VIEW_FROM_CONTENT. Four of them have no daily
breakdown; asking for daily=true drops them and says which, rather than failing the
whole call.
Write
Every one of them returns a preview unless called with confirm=true, and the two
deletions are annotated destructiveHint so a client can warn before running them.
Resources and prompts
Two resources, both scoped to the caller's tenant:
linkedin://connection— the connection status, its permissions, its expiry;linkedin://posts— the posts the server knows about.
Three prompts, in French:
bilan_publications— a performance review over a period, read-only;redaction_publication— drafts a post in the member's voice, and stops before publishing;reponses_commentaires— drafts answers to the pending comments, one by one.
The 60 day wall, and what the server does about it
A LinkedIn member token lives 60 days, and LinkedIn only hands programmatic refresh tokens to approved partners. So for most apps, including this one until proven otherwise, a token cannot be renewed silently: the member has to click again.
The server does what can be done about it:
- a background sweep looks at every token twice a day and asks LinkedIn whether it is still alive;
- if a refresh token exists, it is used, and the new token is stored;
- otherwise
connection_statusstarts saying how many days are left, andreconnect_urlgives a one-click link; - any tool that hits an expired token answers with that same link instead of an opaque 401.
Reconnecting keeps the same tenant, so the MCP client that was already authorized keeps working: nothing to reconfigure on the client side.
Nothing gets published without a human
The confirm gate is the rule the whole write path is built around.
> publie ça sur LinkedIn
publish_post(commentary: "...")
{
"preview": true,
"commentary": "...",
"visibility": "PUBLIC",
"notice": "Rien n'a été envoyé à LinkedIn. Rappelez l'outil avec confirm=true."
}
The call reached the server, was validated, and stopped. Nothing left the process. The
same is true of edit_post, publish_comment, react, delete_post and
delete_comment, and it is covered by tests that assert the LinkedIn client was never
touched.
Isolation
- The tenant is read from the JWT the server itself signed, never from an argument. No
tool takes a
member_id. - The author URN of every write is derived from the tenant, so a member can only ever post as themselves.
- Every edit and delete first looks the post up in that tenant's ledger. Knowing somebody else's post URN is not enough to touch it, and the test suite says so.
- LinkedIn tokens are encrypted at rest with AES-256-GCM and never serialized into a tool result, a resource, or a log line.
ALLOWED_MEMBER_IDScloses the door at sign-in, before a tenant is created.
Develop
$ make help # every target
$ make check # vet + staticcheck + tests
$ make test # tests alone
$ make run # local server, reads .env
$ make build # static binary in bin/
Local run:
$ cp .env.example .env # fill in the four required variables
$ make run
For a local LinkedIn app, declare https://127.0.0.1:8080/linkedin/callback as a
redirect URL. LinkedIn refuses plain http, loopback included.
Endpoints
License
MIT. See LICENSE.