linkedin-mcp
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
MITThis server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues