Skip to main content
Glama
NeerajMohanty

linkedin-publisher

LinkedIn Publisher

Drafting is easy. Publishing is the part that needs a gate.

An open-source MCP connector for publishing explicitly approved text posts from Muse to LinkedIn.

Stateless · No database · No scheduler · Two tools · Apache-2.0

Python 3.12+ MCP License

Independent open-source project. Not affiliated with or endorsed by LinkedIn or Meta.

An agent with your password is not a feature.

An AI assistant can draft a good LinkedIn post. That part is solved.

The unsolved part is the last inch: handing something that writes fluently the ability to speak publicly, as you, on a network where your name is the whole point.

Most integrations answer this by asking for broad access and promising to behave. This one answers it by being too small to misbehave.

Two tools. One of them is read-only.

The other publishes exactly the text it was handed, once, immediately, and then forgets it.

What it does

You      "Write something about what I shipped today."
  ↓
Muse     drafts it · shows you · waits
  ↓
You      read it · change it · approve it
  ↓
LinkedIn Publisher      ← the only step that touches LinkedIn
  ↓
LinkedIn Posts API
  ↓
Your profile

The connector enters at the second-to-last line and leaves immediately after.

It does not write the post. It does not decide what is worth posting. It does not publish on its own initiative, and it has nowhere to queue anything for later.

What it is not

This connector is not trying to become:

  • a scheduler

  • a content generator

  • a social media manager

  • an analytics dashboard

  • an engagement bot

  • a place your drafts live

The absence of a scheduler is the most deliberate decision in the project. An earlier iteration of this codebase had one — durable worker, SELECT … FOR UPDATE SKIP LOCKED, retries, the lot. It worked. It is not shipped, because working was never the objection: a queue publishes when you are not there to change your mind, and it needs a database to hold your words until then.

If you want a post to go out tomorrow morning, Muse keeps the reminder and asks you tomorrow morning. The approval stays next to the publication.

Reasoning in full: docs/LINKEDIN_POLICY_REVIEW.md.

Two tools. That is the whole surface.

linkedin_connection_status

Read-only. Answers whether LinkedIn is connected, which permissions were granted, and when the authorization expires.

Makes no LinkedIn API call, creates nothing, and never returns the access token.

{
  "connected": true,
  "status": "CONNECTED",
  "scopes": ["openid", "profile", "w_member_social"],
  "can_publish": true,
  "reconnect_required": false
}

linkedin_publish_text

Takes approved text. Publishes it to the authenticated member's own profile, immediately, and returns the post id.

This is a real, public, irreversible side effect, and it is annotated as one — destructive, non-idempotent, open-world — so an agent framework treats it as consequential rather than routine. There is no scheduled_at parameter, in the schema or anywhere behind it.

Ambiguous outcomes are never retried. If LinkedIn accepts the request but the response is lost, the tool returns UNKNOWN_OUTCOME and tells you to check your feed. A duplicate public post is worse than an uncertain answer.

How the credential works

Two separate relationships, deliberately never merged:

                  sealed credential              LinkedIn access token
   Muse  ─────────────────────────▶  Connector  ─────────────────────────▶  LinkedIn
                                          │
                                   holds nothing
                                  between requests

The credential Muse sends is the sealed envelope — a Fernet-authenticated blob containing the LinkedIn token, the member id, the granted scopes and the expiry. Validating it is pure computation: decrypt, check, use, discard. There is no session table to look it up in, because there is no table.

The LinkedIn access token never reaches Muse's model context, never appears in a URL, never appears in a tool argument, and never appears in a log.

The honest cost, stated rather than buried: there is no per-user revocation. A credential stops working when the LinkedIn token expires (~60 days), when you revoke the app at LinkedIn, or when the operator rotates the sealing key — which revokes everyone at once.

Full design and the alternatives that were rejected: docs/AUTH_ARCHITECTURE.md.

Privacy by design

The connector stores no post content, no drafts, no publishing history, no scheduled jobs, no user records and no access tokens. Not encrypted-at-rest; absent.

This is checked rather than asserted. scripts/verify_zero_persistence.py runs a real publish against a mocked LinkedIn inside a scratch directory, then inspects the filesystem, the imports, the configuration and the logs:

python scripts/verify_zero_persistence.py     # 15 checks → ZERO_PERSISTENCE_VERIFIED

Logs are redacted structurally — an allow-list formatter, not a blocklist of patterns — so a careless future extra= cannot leak. Where the post body would be, the connector writes a character count:

{"ts":"...","level":"INFO","logger":"connector.server","message":"published",
 "tool":"linkedin_publish_text","text_length":137,"latency_ms":979.9}

Further reading: PRIVACY.md · SECURITY.md · docs/STORAGE_AUDIT.md

Design principles

Explicit user action

The connector publishes only what it was explicitly handed

Minimal surface

Two tools, one of them read-only

Stateless

Nothing is held between requests

No scheduler

Approval stays next to publication

Official API

LinkedIn's versioned Posts API, nothing scraped

No retry on ambiguity

A duplicate public post is the worst outcome

Verified, not asserted

Every claim above has a check that fails if it stops being true

Quick start

git clone https://github.com/NeerajMohanty/LinkedIn-publisher.git
cd LinkedIn-publisher

python -m venv .venv
source .venv/bin/activate          # Windows: .venv\Scripts\activate

pip install -r requirements-connector.txt
cp .env.example .env               # Windows: copy .env.example .env

You need a LinkedIn application with two products enabled — Sign In with LinkedIn using OpenID Connect and Share on LinkedIn (this is what grants w_member_social).

Generate a sealing key and fill in .env:

python -m connector.keygen         # → CONNECTOR_SEALING_KEY
LINKEDIN_CLIENT_ID=your-client-id
LINKEDIN_CLIENT_SECRET=your-client-secret
LINKEDIN_REDIRECT_URI=http://localhost:8080/connect/callback
CONNECTOR_SEALING_KEY=the-key-you-just-generated
PUBLIC_BASE_URL=http://localhost:8080

Add LINKEDIN_REDIRECT_URI to your LinkedIn app's Authorized redirect URLs, exactly — same scheme, host and path, no trailing slash.

Run it, then authorize:

python -m connector

Open http://localhost:8080/connect, approve on LinkedIn, and the callback hands back your connector credential once. Treat it like a password.

Related MCP server: linkedin-mcp-server

Connecting an MCP client

The configuration below is the one that was actually tested — a static Authorization header, which is what let Muse Code discover both tools.

{
  "schema_version": 1,
  "provider": "meta",
  "model": "your-model",
  "mcp_servers": {
    "linkedin-publisher": {
      "transport": "streamable_http",
      "url": "https://your-connector-host/mcp",
      "headers": {
        "Authorization": "Bearer YOUR_CONNECTOR_CREDENTIAL"
      },
      "enabled": true,
      "mode": "required"
    }
  }
}

Do not commit your connector credential. It is a bearer credential: whoever holds it can publish to your profile until the underlying LinkedIn token expires. Environment-variable interpolation in this file has not been verified, so the header is shown literally rather than implying a substitution that may not work.

Deployment

The reference deployment runs on Azure App Service as a small stateless container. There is no database to provision and no volume to mount, because there is nothing to write.

Connector

https://linkedin-publisher-connector.azurewebsites.net

MCP endpoint

https://linkedin-publisher-connector.azurewebsites.net/mcp

Health

https://linkedin-publisher-connector.azurewebsites.net/healthz

Secrets live in server-side application settings, never in the image. Other hosts — Fly.io, Render, Cloud Run — are covered in docs/DEPLOYMENT.md.

Verification

python -m pytest tests -q                    # 131 tests, LinkedIn mocked throughout
python scripts/verify_zero_persistence.py    # 15 checks → ZERO_PERSISTENCE_VERIFIED
python scripts/verify_deployment.py <url>    # 26 checks against a running deployment
python -m ruff check . && python -m ruff format --check .

No automated test publishes a real post. The deployment verifier performs a genuine MCP handshake over the public internet, confirms unauthenticated and forged credentials are rejected, and checks that exactly two tools are exposed with the expected annotations.

Live member publication through the deployed connector was verified on 20 September 2026POST /rest/posts returned 201 with a post id, using an access token carrying only openid profile w_member_social.

Project structure

connector/            the entire runtime
  server.py           two tools, three HTTP routes
  sealing.py          the sealed credential envelope
  auth.py             RFC 6750 bearer middleware
  oauth.py            LinkedIn authorization, stateless CSRF
  logging.py          allow-list redaction
  linkedin/client.py  the only code that talks to LinkedIn
tests/connector/      131 tests
scripts/              verifiers, site builder, one-shot publish helper
docs/                 decisions, policy review, deployment, and the public site
Dockerfile
requirements-connector.txt

The runtime dependencies are mcp, httpx, cryptography, uvicorn and pydantic. That is the whole list — no ORM, no queue, no cache.

Contributing

Issues and pull requests are welcome. Keep changes focused, keep the connector stateless, and add tests for behaviour you touch. See CONTRIBUTING.md.

Before proposing a feature, the useful question is the one this project keeps asking itself:

Does this make the publishing step safer, or does it just make the connector bigger?

Security

Report vulnerabilities privately — see SECURITY.md. Please do not open a public issue for anything involving credential exposure, and never paste a credential, access token or client secret into an issue.

License

Apache License 2.0. Third-party notices: NOTICE.


Drafting is easy. Publishing is the part that needs a gate.

Independent open-source project. LinkedIn is a trademark of LinkedIn Corporation. Meta and Muse are trademarks of Meta Platforms, Inc. This project is not affiliated with, sponsored by, or endorsed by LinkedIn or Meta.

Related MCP Connectors

Related MCP Servers

  • A
    license
    A
    quality
    B
    maintenance
    Enables LinkedIn API integration for managing profiles, posts, feed, connections, and sending messages through MCP-compatible clients.
    10
    8 npm
    MIT
  • F
    license
    Not graded
    quality
    C
    maintenance
    MCP server for LinkedIn API integration. Enables authentication, profile access, connections, search, messaging, and feed management via OAuth2.
    -
  • A
    license
    Not graded
    quality
    B
    maintenance
    MCP server that lets you publish text and image posts to LinkedIn, fetch your profile, and check auth status using natural language.
    MIT
  • A
    license
    A
    quality
    B
    maintenance
    Local-first MCP server using LinkedIn's official OAuth and REST APIs to connect a member account, read profile, and publish text posts.
    4
    82 npm
    MIT