Skip to main content
Glama
codebytemirza

LinkedIn Poster MCP

README.md
<div align="center">

<img src="assets/banner.jpg" width="100%" alt="LinkedIn Poster MCP banner">

**LinkedIn Poster MCP — Publish to LinkedIn via natural language.**

**Post to LinkedIn without leaving the chat.**

</div>

<p align="center">
  <img src="https://img.shields.io/badge/status-MCP%20Server-0A66C2" alt="MCP server">
  <img src="https://img.shields.io/badge/Node.js-24%2B-339933?logo=node.js&logoColor=white" alt="Node 24+">
  <img src="https://img.shields.io/badge/transport-Streamable%20HTTP%20%2B%20stdio-4DC8FF" alt="transport">
  <img src="https://img.shields.io/badge/storage-Upstash%20Redis-DC382D" alt="Upstash Redis">
  <img src="https://img.shields.io/badge/auth-LinkedIn%20OAuth2-0A66C2" alt="LinkedIn OAuth2">
  <img src="https://img.shields.io/github/license/codebytemirza/linkedin-post-mcp" alt="License">
</p>

---

A **Model Context Protocol** server that turns any MCP client into a LinkedIn publisher — text posts, image posts with alt text, profile lookups, and token-health checks, all driven by natural language instead of the LinkedIn API console.

```text
› Post the launch photo to LinkedIn, caption it about our v2 release.
// MCP tool call resolved automatically
{
  "tool": "create_image_post",
  "caption": "Shipping v2 today",
  "images": ["launch.png"]
}
// polling upload status… AVAILABLE
ok post created — urn:li:share:7183…
```

## Highlights

| | | | |
|---|---|---|---|
| **Never a blank image** | **Two transports** | **Admin dashboard** | **Bearer-guarded endpoint** |
| Polls LinkedIn's Images API until status is `AVAILABLE` before the post is created. | Streamable HTTP on Vercel for production, plus a bundled stdio entry for local clients. | Optional panel surfacing auth status and tool call logs at a glance. | Every call to `/api/mcp` requires a bearer token before protocol handling begins. |

## MCP tools

Four tools, one bearer token. Every tool call authenticates through the same Redis-backed token — no per-call re-auth.

| Tool | Input | Returns |
|------|-------|---------|
| `create_post` | `text`, `visibility?` | created post ID |
| `create_image_post` | `caption`, `images[1..20]`, `visibility?` | created post ID |
| `get_profile` | — | name + email |
| `check_auth_status` | — | token validity + expiry |

## Architecture

**Stateless endpoint, stateful token.**

```text
api/
  authorize.ts     GET   start LinkedIn OAuth (CSRF state in Redis)
  callback.ts      GET   exchange code for tokens, store, success page
  mcp.ts           POST  MCP endpoint, bearer-auth, tools
  dashboard.ts     GET   admin dashboard API
frontend/                React + Tailwind + MUI dashboard source -> public/
lib/
  config.ts               env vars, URLs, Redis client
  linkedin-auth.ts        token exchange, refresh, access-token helper
  linkedin-api.ts         profile, create_post, create_image_post
  logging.ts              structured logs to Redis
  errors.ts               NotAuthorizedError, LinkedInError
mcp-stdio.mjs       esbuild-bundled stdio entry
```

Facts worth remembering:

- **Tokens** live under a single Redis key, `linkedin:tokens`.
- `/api/mcp` is stateless and requires `Authorization: Bearer <MCP_AUTH_TOKEN>` on every request.
- **All LinkedIn calls** use raw `fetch()` — no LinkedIn SDK dependency.

## AI agent guidelines (read this, or image posts get stuck)

These rules are also baked into each tool's MCP description so Claude, GPT, and
any other client receives them automatically. Following them prevents the
"stuck tool call" failure mode:

1. **Never fabricate image bytes.** Only call `create_image_post` after the user
   provides an actual image file. Read that file and send its true base64.
   If you don't have the file, ask the user for it instead of guessing.
2. **One image = one response.** Placeholder or truncated base64 is rejected
   with a fast `400` error — the server validates JPEG/PNG/GIF magic bytes
   before it ever contacts LinkedIn.
3. **mediaType must match the bytes**, or omit it.
4. **Slow is not failed.** After upload, LinkedIn processes images
   asynchronously for up to ~30s before the post is created. A long wait is
   normal; do not abort or report failure while it runs.
5. **Timeouts are terminal.** A `504` means processing didn't finish in time —
   report it, don't retry the same bytes.
6. **Auth errors are not retryable.** "LinkedIn is not authorized" means the
   user must open `/api/authorize` in a browser. Retrying wastes a turn.

## OAuth flow

One authorization, sixty days of posting.

```text
Client -> /api/authorize -> LinkedIn OAuth -> /callback -> Redis -> MCP tools
```

| Step | What happens |
|------|--------------|
| **01** | `/api/authorize` creates a CSRF `state`, stores it in Redis for 10 minutes, redirects to LinkedIn. |
| **02** | LinkedIn authenticates the user and redirects back to `/api/callback?code=...&state=...`. |
| **03** | `/api/callback` validates `state`, exchanges the code, saves tokens to Redis. |
| **04** | Tools call `getValidAccessToken()` — refreshing near expiry, or throwing `NotAuthorizedError`. |

> **Note:** LinkedIn issues a `refresh_token` only for apps provisioned with programmatic refresh. On the standard scope set, access tokens last 60 days and the user re-authorizes on expiry — `check_auth_status` always reports the real state.

## Image upload

**Why posts never render blank.**

1. **Register** — the server registers the upload and receives a signed `uploadUrl`.
2. **Upload** — image bytes are uploaded directly to that signed URL.
3. **Poll -> post** — status is polled until `AVAILABLE`, then — and only then — the post is created.

## Setup

### Prerequisites

- Node.js **24+**
- LinkedIn Developer app with approved scopes: `openid profile email w_member_social`
- An Upstash Redis database

### Environment variables

Set these in Vercel and in a root `.env` for local development:

| Variable | Purpose |
|----------|---------|
| `LINKEDIN_CLIENT_ID` | LinkedIn app client ID |
| `LINKEDIN_CLIENT_SECRET` | LinkedIn app client secret |
| `LINKEDIN_REDIRECT_URI` | Must match a whitelisted redirect URL exactly |
| `UPSTASH_REDIS_REST_URL` | Upstash Redis REST URL |
| `UPSTASH_REDIS_REST_TOKEN` | Upstash Redis REST token |
| `MCP_AUTH_TOKEN` | Bearer token MCP clients send to `/api/mcp` |

These files are git-ignored — never commit secrets.

### Local development

```bash
npm install
vercel dev          # http://localhost:3000/api/...
npm run typecheck   # tsc --noEmit
```

### Redirect URIs

```text
local    http://localhost:3000/api/callback
prod     https://<your-app>.vercel.app/api/callback
```

Whitelist the exact URL in the LinkedIn Developer Portal — LinkedIn matches redirects with no tolerance for query params.

## Client setup

Remote or local — pick one.

<details>
<summary><b>Remote — Streamable HTTP</b></summary>

- **Type:** Custom / MCP server, Streamable HTTP
- **Endpoint:** `https://<your-deployed-url>/api/mcp`
- **Header:** `Authorization: Bearer <MCP_AUTH_TOKEN>`
</details>

<details>
<summary><b>Local — stdio</b></summary>

Point the client at the bundled entry:

```text
node /path/to/linkedin-post-mcp/mcp-stdio.mjs
```
</details>

## Deploy

Ship it to Vercel.

```bash
vercel           # preview
vercel --prod    # production
```

- `vercel.json` pins `api/**` to the Node.js runtime.
- Set the production callback URL for LinkedIn.
- After deploying, open `/api/authorize` once — then every tool works.

## Security

What's actually guarded.

- **Secrets** come from environment variables only — nothing is logged.
- **Endpoint** — the MCP endpoint requires a bearer token before protocol handling.
- **CSRF** — OAuth `state` guards against request forgery and is one-time-use with a short TTL.

---

<p align="center">
<sub>Node.js 24+ · Streamable HTTP + stdio · Upstash Redis · LinkedIn OAuth2</sub>
</p>