Skip to main content
Glama
README.md
# linkedin-mcp

An **MCP (Model Context Protocol) server** for LinkedIn **and job hunting**:
authenticate via OAuth 2.0, read your profile, draft and analyze posts with an LLM,
publish posts to your feed, search free job boards, and track applications — all
through tools any MCP client (Claude, Cursor, custom agents) can call.

```
User / agent
      │  (MCP: stdio or streamable HTTP)
      ▼
 linkedin-mcp (FastMCP + FastAPI)
      │  ├── LinkedIn OAuth 2.0 (authorization code + refresh)
      │  ├── LinkedIn REST API  (profile, email, create post)
      │  ├── Job boards  (Remotive · Arbeitnow · RemoteOK · Jobicy — keyless)
      │  ├── Application tracker  (local JSON store)
      │  └── LLM provider layer (Grok · OpenAI · Ollama · unorouter — swappable via env)
      ▼
  LinkedIn API  /  job board APIs  /  your LLM provider
```

## What's implemented (Phase 1 + a taste of Phase 3)

| Tool | What it does |
| --- | --- |
| `auth_status` | Check OAuth state; returns the login URL or a clear "what's missing" message |
| `get_profile` | Member profile (name, picture, locale, email) via OpenID Connect `/v2/userinfo` |
| `get_email` | Member email (requires the `email` scope) |
| `create_post` | Publish a text or **image** post via the current **Posts API** (`/rest/posts`) |
| `draft_post` | Generate a LinkedIn post with the configured LLM provider |
| `analyze_post` | Expert critique + suggested rewrites of a draft via the LLM |
| **`search_jobs`** | Search Remotive/Arbeitnow/RemoteOK/Jobicy — free, keyless, no LinkedIn permissions |
| **`match_jobs_to_profile`** | LLM-scored fit for listings from `search_jobs` against your profile |
| **`prepare_application`** | One call → fit assessment + cover letter + recruiter DM + interview prep |
| **`track_application`** | Save an application locally (company, role, URL, status, notes) |
| **`list_applications`** | List tracked applications, filterable by status |
| **`update_application`** | Move an application along: saved → applied → interview → offer/rejected |
| **`followup_applications`** | Prioritized view of what needs action next |
| `analyze_job_fit` | Compare a job description against your profile: fit score, gaps, talking points |
| `draft_application` | Tailored cover letter or recruiter DM from a job description + your profile |
| `draft_job_post` | Open-to-work / job-search announcement post |
| `schedule_post` | Queue a post to be published automatically at a future time |
| `list_scheduled_posts` | List queued posts, filterable by status |
| `cancel_scheduled_post` | Remove a queued post before it publishes |
| `publish_scheduled_post_now` | Publish a queued post immediately (handy for testing) |
| Resource `linkedin://profile` | Profile exposed as an MCP resource |

### Job search & application tracking

LinkedIn offers **no public jobs API**, so search goes through four free keyless
boards (verified live): **Remotive**, **Arbeitnow**, **RemoteOK**, and **Jobicy**.
The typical agent flow:

```
search_jobs("python fastapi")  →  match_jobs_to_profile(listings)
       →  prepare_application(jd)  →  track_application(...)
       →  draft_job_post(...)  →  create_post / schedule_post
```

Applications are stored in `.data/applications.json` (gitignored). Board outages
never break a search: each board's error is reported per-board under `board_errors`.

### Image posts

Pass a local JPG/PNG/GIF path to `create_post` (or `schedule_post`): the file is
uploaded through LinkedIn's **Images API** (`/rest/images?action=initializeUpload` →
signed PUT → referenced by `urn:li:image:` URN in the post) and published with
optional `alt_text`.

### Post scheduling

`schedule_post` stores the post in `.data/scheduled_posts.json`; while the **web
server is running** it polls every `SCHEDULER_POLL_SECONDS` (default 30 s) and
publishes posts whose time has arrived. `publish_at` is ISO-8601 — naive values
are treated as UTC, or include an offset (e.g. `2026-09-05T15:30:00+05:30`).
Failures (e.g. token expired) are recorded on the entry as `status="failed"` with
the error, so `list_scheduled_posts` shows exactly what happened.

Everything the LLM does goes through a **provider-agnostic layer** — you switch vendors
by changing one env var, no code changes:

```dotenv
LLM_PROVIDER=grok     # or: openai | ollama | unorouter
```

## Prerequisites

- Python 3.11+ (tested on 3.12)
- A [LinkedIn Developer](https://www.linkedin.com/developers) account
- An LLM provider key (or a local [Ollama](https://ollama.com) install — free)

## Step 1 — Create the LinkedIn Developer App

1. Go to <https://www.linkedin.com/developers> → **Create app**.
2. Fill in the basics (name, LinkedIn Page, logo) and create it.
3. In **Products**, request:
   - **Sign In with LinkedIn using OpenID Connect** — grants `openid`, `profile`, `email`
   - **Share on LinkedIn** — grants `w_member_social`, required to post
4. In **Auth**, note your **Client ID** and **Client Secret**, and add the
   **Authorized redirect URL**:
   ```    http://127.0.0.1:8000/auth/linkedin/callback
   ```
   (For production you'll use your deployed HTTPS URL instead — Phase 2.)

## Step 2 — Configure

```bash
python -m venv .venv
source .venv/Scripts/activate        # Git Bash on Windows; on macOS/Linux: .venv/bin/activate
pip install -e ".[dev]"

cp .env.example .env                 # then fill in the values
```

`.env` essentials:

```dotenv
LINKEDIN_CLIENT_ID=your-client-id
LINKEDIN_CLIENT_SECRET=your-client-secret
LLM_PROVIDER=grok
GROK_API_KEY=your-xai-key
```

> **About xAI billing:** xAI currently does not accept Indian payment cards for its API.
> If that's a blocker, just set `LLM_PROVIDER=openai` or `LLM_PROVIDER=ollama` —
> nothing else changes.

## Step 3 — Run

**Web mode** (personal dashboard UI + OAuth + MCP over HTTP):

```bash
python -m linkedin_mcp web
# or: uvicorn linkedin_mcp.app:app --reload
```

Open <http://localhost:8000/> for the **personal dashboard** — no MCP client needed:

- **Compose & publish** posts with an image picker, or schedule them for later
- **Upload documents** (PDF/DOCX/TXT/MD, 10 MB max) — mark your resume as
  *profile* and every job-matching tool uses your real skills instead of bare
  profile metadata
- **Job search** across the four boards + one-click **LLM match** against your profile
- **Application tracker** with status buttons (saved → applied → interview → …)
- Live **scheduled-posts** list with publish-now / cancel

The MCP endpoint is still available for clients at:

```
http://localhost:8000/mcp/
```

**Stdio mode** (for Claude Desktop / local MCP clients):

```bash
python -m linkedin_mcp
```

## Step 4 — Connect an MCP client

Claude Desktop / Claude Code `claude mcp add` example (streamable HTTP):

```bash
claude mcp add linkedin --transport http http://localhost:8000/mcp/
```

Or, from any JSON-RPC client, call `auth_status` first — it tells you exactly what's
missing (credentials, login, tokens) instead of failing blindly.

## OAuth & token handling

- Authorization-code flow with a per-login `state` (validated on callback; 10 min TTL).
- Access + refresh tokens are persisted to `LINKEDIN_TOKEN_FILE` (default `.data/tokens.json`).
- **Expiry/refresh handling:** every API call goes through `TokenManager`, which
  transparently refreshes the access token when it's within 5 minutes of expiry.
  If no refresh token is available (LinkedIn grants those to approved apps only),
  tools raise a clear "re-authorize" error.
- The current LinkedIn API version header (`Linkedin-Version`) is configurable via
  `LINKEDIN_API_VERSION` — bump it when LinkedIn sunsets the version you use
  (check <https://learn.microsoft.com/en-us/linkedin/marketing/>).

## LLM provider swap

| `LLM_PROVIDER` | Requires | Notes |
| --- | --- | --- |
| `grok` | `GROK_API_KEY` | xAI, OpenAI-compatible (`https://api.x.ai/v1`) |
| `openai` | `OPENAI_API_KEY` | `https://api.openai.com/v1` |
| `ollama` | local Ollama running | `http://localhost:11434/v1`, no key |
| `unorouter` | `UNOROUTER_API_KEY` | any other OpenAI-compatible endpoint (`https://api.unorouter.com/v1`) |

All three share one implementation (`OpenAICompatProvider`) because they all speak
OpenAI's chat-completions protocol; the factory in `llm/factory.py` validates the key
and produces a provider with a helpful error if you haven't configured one.

## Project layout

```
src/linkedin_mcp/
├── config.py            # pydantic-settings, all env vars, provider selection
├── errors.py            # ConfigurationError, NeedsReauthError, LinkedInAPIError, LLMError
├── services.py          # shared wiring (store, oauth, token manager, client, scheduler)
├── scheduling.py        # scheduled-post store + background publisher loop
├── jobs.py              # keyless job-board search (Remotive/Arbeitnow/RemoteOK/Jobicy)
├── applications.py      # local application tracker store
├── documents.py         # uploads (pdf/docx/txt/md) + resume/profile summary
├── api.py               # REST endpoints backing the dashboard UI
├── server.py            # FastMCP server: 23 tools + resources
├── app.py               # FastAPI app: /auth/* routes + mounted /mcp endpoint
├── __main__.py          # CLI: `python -m linkedin_mcp [web]`
├── llm/                 # swappable LLM layer (base protocol, factory, provider)
└── linkedin/            # OAuth flow, token storage/refresh, REST client
tests/                   # pytest (mocked HTTP), 66 tests
```

## Security notes

- **Never commit `.env` or `.data/`** — both are gitignored.
- Tokens are stored **in plaintext on disk** (local dev). For a deployed version, move
  them to an encrypted store / server-side secret manager.
- The OAuth `state` registry is in-memory — fine for a single-user local app; replace
  with a signed cookie or session store when deploying.
- The LLM layer only ever sends what you pass to `draft_post`/`analyze_post` to the
  provider — the server never silently uploads your LinkedIn data.

## Tests

```bash
pytest                 # 59 tests: tokens/refresh, LLM factory, LinkedIn client,
                       # job boards, application tracker, tools (all mocked HTTP)
ruff check src tests   # lint
```

## Roadmap

- **Phase 2 — free hosting:** containerize, deploy on a free tier (limits change often —
  verify current offers), HTTPS, point `LINKEDIN_REDIRECT_URI` at the deployed URL.
- **Phase 3 — more tools:** `get_post_statistics` (requires approved analytics
  permissions), video/document posts, an agent loop where the LLM picks tools itself,
  Adzuna board (free key, aggregates thousands of sites incl. India locations),
  resume storage + per-JD tailoring.
- Hardening: signed-cookie state, token encryption, rate-limit handling with retries.

## License

MIT