linkedin-publisher
README.md
<div align="center">
<img src="assets/linkedin-publisher-logo.png" alt="" width="148" height="148">
# 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
<br>
[](https://www.python.org/)
[](https://modelcontextprotocol.io/)
[](LICENSE)
<sub>Independent open-source project. Not affiliated with or endorsed by LinkedIn or Meta.</sub>
</div>
<br>
# 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.
<br>
# 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.
<br>
# 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](docs/LINKEDIN_POLICY_REVIEW.md).
<br>
# 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.
```json
{
"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.
<br>
# 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](docs/AUTH_ARCHITECTURE.md).
<br>
# 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:
```bash
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:
```json
{"ts":"...","level":"INFO","logger":"connector.server","message":"published",
"tool":"linkedin_publish_text","text_length":137,"latency_ms":979.9}
```
Further reading: [PRIVACY.md](PRIVACY.md) · [SECURITY.md](SECURITY.md) · [docs/STORAGE_AUDIT.md](docs/STORAGE_AUDIT.md)
<br>
# 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 |
<br>
## Quick start
```bash
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`:
```bash
python -m connector.keygen # → CONNECTOR_SEALING_KEY
```
```ini
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:
```bash
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.
<br>
## 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.
```json
{
"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.
<br>
## 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](docs/DEPLOYMENT.md).
<br>
## Verification
```bash
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 2026** — `POST /rest/posts` returned `201` with a post id, using an access token carrying only `openid profile w_member_social`.
<br>
## 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.
<br>
## Contributing
Issues and pull requests are welcome. Keep changes focused, keep the connector stateless, and add tests for behaviour you touch. See [CONTRIBUTING.md](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](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](LICENSE). Third-party notices: [NOTICE](NOTICE).
<br>
---
<br>
<div align="center">
### Drafting is easy. Publishing is the part that needs a gate.
Independent open-source project.<br>
LinkedIn is a trademark of LinkedIn Corporation. Meta and Muse are trademarks of Meta Platforms, Inc.<br>
This project is not affiliated with, sponsored by, or endorsed by LinkedIn or Meta.
</div>
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues