Skip to main content
Glama
selvin-paul-raj

LinkedIn MCP Server

README.md
# LinkedIn MCP Server

A production-quality [Model Context Protocol](https://modelcontextprotocol.io) server that exposes LinkedIn
actions (post, comment, search, connections, messaging, and anything else Composio's LinkedIn toolkit adds)
to Claude and ChatGPT. Built for **personal, single-user** use with [Composio](https://composio.dev) as the
auth/action layer, deployed on [Alpic](https://alpic.ai).

## Features

- **Fully dynamic tool discovery** — every LinkedIn action Composio exposes is registered as an MCP tool at
  startup and refreshed on demand via `linkedin_mcp_list_tools`. No tool names are hardcoded.
- **Automatic LinkedIn auth** — on startup, checks whether the fixed user is connected; if not, starts the
  OAuth flow, logs the authorization URL, and waits (bounded timeout) for the connection to complete.
- **Structured logging** — JSON logs via `structlog`, rotating log file under `logs/`, one line per startup,
  shutdown, tool call (with latency and outcome), and exception.
- **Local stats tracking** — total/successful/failed calls, average latency, last execution, startup time,
  and uptime, persisted to a local JSON file (`linkedin_mcp_health` tool, or read `STATS_FILE` directly).
- **Health reporting** — `linkedin_mcp_health` reports server, LinkedIn connection, and Composio reachability.
- **Never crashes** — every Composio/network/auth failure is caught and returned as a normal (but
  error-flagged) tool result, never an unhandled exception.
- **Streamable-HTTP transport**, binding `PORT`/`HOST` — ready for Alpic's hosting model.

## Architecture

```
src/
├── server.py           # FastMCP app + entrypoint; wires everything together
├── composio_client.py  # Thin wrapper around the Composio SDK (LinkedIn toolkit only)
├── tool_registry.py    # Dynamic MCP tool list/dispatch: static utility tools + every LinkedIn action
├── config.py           # Settings loaded from environment / .env
├── logger.py           # structlog + rotating file handler setup
├── stats.py            # JSON-backed call statistics tracker
├── health.py           # Health status aggregation
├── models.py            # Shared Pydantic models
└── utils.py             # Retry/backoff decorator, Timer
```

The four static tools (`linkedin_mcp_ping`, `linkedin_mcp_health`, `linkedin_mcp_version`,
`linkedin_mcp_list_tools`) are always available. Every other tool name is a Composio LinkedIn action slug
(e.g. `LINKEDIN_CREATE_LINKED_IN_POST`, `LINKEDIN_GET_MY_INFO`, `LINKEDIN_CREATE_COMMENT_ON_POST`, ...) —
call `linkedin_mcp_list_tools` at any time to see the current live list with descriptions.

## Available LinkedIn Tools

The server's Composio session is scoped to exactly these 22 LinkedIn actions (see
`ENABLED_LINKEDIN_TOOLS` in `src/composio_client.py`) — this is what's actually enabled for the
connected account, not every action Composio's LinkedIn toolkit supports. Full JSON Schemas
(all fields, nested objects, enums) are always available live via the `linkedin_mcp_list_tools`
tool; this table is a quick-reference summary of required parameters and when to use each one.

| Tool | Required parameters | Use case |
|---|---|---|
| `LINKEDIN_CREATE_LINKED_IN_POST` | `author` (urn:li:person\|organization), `commentary` (text, ≤3000 chars) | Publish a post — as yourself or a company page you administer. Optional: `images`, `visibility`, `distribution` (audience targeting), `lifecycleState: DRAFT`, `contentCallToActionLabel`. |
| `LINKEDIN_CREATE_ARTICLE_OR_URL_SHARE` | `author`, `specificContent` (media URL), `visibility` | Share a link/article with optional commentary, via the older UGC Posts API. |
| `LINKEDIN_CREATE_COMMENT_ON_POST` | `target_urn`, `actor`, `object`, `message.text` | Comment on a post, or reply to a comment via `parentComment`. Supports @-mentions and image attachments. |
| `LINKEDIN_DELETE_LINKED_IN_POST` | `share_id` | Delete a post by its share id/URN. |
| `LINKEDIN_DELETE_POST` | `post_urn` (ugcPost or share) | Delete a post via the newer Posts API; idempotent. |
| `LINKEDIN_DELETE_UGC_POST` | `ugc_post_urn` | Delete a post via the legacy UGC Post API; idempotent. |
| `LINKEDIN_GET_MY_INFO` | *(none)* | Get your own profile (name, headline, person URN) — needed to get your `person_id` before posting. |
| `LINKEDIN_GET_PERSON` | `person_id` | Look up another member's basic profile by id. |
| `LINKEDIN_GET_POST_CONTENT` | `post_id` | Fetch the full content/metadata of an existing post by URN. |
| `LINKEDIN_LIST_REACTIONS` | `entity` | See who reacted to a post/comment and what reaction type. Optional: `sort`, `count`, `start`. |
| `LINKEDIN_GET_COMPANY_INFO` | *(none)* | List organizations you administer/manage — the way to find your company page's URN for posting as an org. |
| `LINKEDIN_GET_NETWORK_SIZE` | `organization_id` | Get a company page's follower count. |
| `LINKEDIN_GET_ORG_PAGE_STATS` | `organization` | Company page engagement stats (views, button clicks) — lifetime, or time-bound with `timeRangeStart`/`timeRangeEnd`/`timeGranularityType`. Requires org ADMINISTRATOR role. |
| `LINKEDIN_GET_SHARE_STATS` | `organizational_entity` | Company page content performance (impressions, clicks, likes, comments, shares). Optional `time_intervals` for a time-bound window. |
| `LINKEDIN_GET_AD_TARGETING_FACETS` | *(none)* | Discover which ad targeting categories exist (locations, industries, job functions, ...) before building a campaign. |
| `LINKEDIN_SEARCH_AD_TARGETING_ENTITIES` | `query`, `facet` | Typeahead-search a targeting facet (e.g. resolve "United States" to its geo URN) before using it in targeting criteria. |
| `LINKEDIN_GET_AUDIENCE_COUNTS` | `targetingCriteria` | Estimate the audience size for a given (URL-encoded) targeting criteria string, for ad campaign planning. |
| `LINKEDIN_INITIALIZE_IMAGE_UPLOAD` | `owner` | Step 1 of the newer image-upload flow: get a presigned `upload_url` + image URN, PUT the image bytes, then pass the URN to `LINKEDIN_CREATE_LINKED_IN_POST`. |
| `LINKEDIN_REGISTER_IMAGE_UPLOAD` | `owner_urn` | Step 1 of the legacy image-upload flow for feed shares; same upload-then-reference pattern as above. |
| `LINKEDIN_GET_IMAGE` | `image_urn` | Get one image's status, dimensions, and download URL. |
| `LINKEDIN_GET_IMAGES` | `ids` (array) | Batch image metadata lookup. |
| `LINKEDIN_GET_VIDEOS` | one of `video_urn` / `video_ids` / `associated_account` | Get video metadata — single, batch, or every video for a sponsored account (paginated via `count`/`start`). |

## Installation

Requires Python 3.12+ and [uv](https://docs.astral.sh/uv/).

```bash
git clone <this-repo>
cd linkedin-mcp
uv venv
uv pip install -e ".[dev]"
```

## Configuration

Copy `.env.example` to `.env` and fill in your Composio API key:

```bash
cp .env.example .env
```

| Variable                  | Required | Default            | Description                                                    |
|----------------------------|----------|---------------------|------------------------------------------------------------------|
| `COMPOSIO_API_KEY`         | Yes      | —                   | Your Composio API key. Get one at https://app.composio.dev       |
| `COMPOSIO_USER_ID`         | Yes      | —                   | Your Composio account's user id (from the dashboard) — tool calls are scoped to this user |
| `COMPOSIO_PROJECT_ID`      | No       | —                   | Composio project id, for reference only (not sent to the SDK)    |
| `COMPOSIO_ORG_ID`          | No       | —                   | Composio org id, for reference only (not sent to the SDK)        |
| `COMPOSIO_ORG_MEMBER_EMAIL`| No       | —                   | Composio org member email, for reference only                    |
| `LINKEDIN_AUTH_CONFIG_ID`  | No       | Composio default    | Use a specific LinkedIn auth config instead of the default one   |
| `LOG_LEVEL`                | No       | `INFO`              | `DEBUG` / `INFO` / `WARNING` / `ERROR`                            |
| `LOG_DIR`                  | No       | `logs`              | Directory for the rotating log file                               |
| `STATS_FILE`               | No       | `logs/stats.json`   | Path to the persisted stats JSON file                             |
| `CONNECT_TIMEOUT_MS`       | No       | `300000` (5 min)    | Max time to wait for LinkedIn OAuth to complete at startup        |
| `PORT`                     | No       | `8000`              | Bind port (Alpic sets this automatically at deploy time)          |

Never hardcode `COMPOSIO_API_KEY` anywhere — it's read exclusively from the environment.

## Running locally

```bash
uv run python -m src.server
```

On first run, if LinkedIn isn't connected yet for the fixed user (`selvin`), the server logs an
authorization URL and waits for you to complete the OAuth flow in a browser before finishing startup.

Test it's alive:

```bash
curl -X POST http://localhost:8000/mcp \
  -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'
```

### Running with Docker

```bash
docker build -t linkedin-mcp .
docker run --rm -p 8000:8000 --env-file .env linkedin-mcp
```

## Deploying to Alpic

```bash
npm install -g alpic   # once
alpic login            # or set ALPIC_API_KEY

alpic environment-variable add --env-file .env --environment-id <id>
alpic deploy --runtime python3.13
```

Alpic prints the live MCP server URL and a `/try` playground URL on success. See
`alpic deployment logs --deployment-id <id>` if a deploy fails — check that `COMPOSIO_API_KEY` is set on the
target environment first (missing env vars are the most common startup crash cause).

## Connecting from Claude Desktop

1. Deploy first (or run locally and expose it, e.g. with `ngrok http 8000`).
2. In Claude Desktop: **Settings → Connectors → Add custom connector**.
3. Paste the Alpic-hosted MCP URL (ends in `/mcp`).
4. Claude will list `linkedin_mcp_ping`, `linkedin_mcp_health`, and every discovered LinkedIn action.

## Connecting from ChatGPT

1. In ChatGPT: **Settings → Connectors → Advanced settings → Developer mode** (required for full custom tool
   calling, not just search/fetch).
2. **Add custom connector**, paste the Alpic-hosted MCP URL.
3. Enable the connector in a chat and ChatGPT can call any LinkedIn action directly.

## Troubleshooting

| Symptom | Likely cause | Fix |
|---|---|---|
| Server won't start / crashes immediately | `COMPOSIO_API_KEY` missing | Set it before first deploy/run — this is the one thing that must be present |
| `linkedin_mcp_health` shows `linkedin_connected: false` | OAuth never completed | Check server logs for the authorization URL, visit it, then call `linkedin_mcp_list_tools` to refresh |
| `linkedin_mcp_health` shows `composio_reachable: false` | Bad API key or Composio outage | Verify `COMPOSIO_API_KEY` is valid; check https://status.composio.dev |
| A LinkedIn tool call errors out | Expired auth, missing scope, or bad input | The error message is returned verbatim from Composio/LinkedIn in the tool result — read it, it's usually actionable |
| New Composio LinkedIn action doesn't show up | Local schema cache | Call `linkedin_mcp_list_tools` to force a refresh |
| Alpic deploy fails at "start" phase | Missing env var or wrong port binding | `alpic environment-variable list --environment-id <id>`; the server always binds `PORT` |

## Testing

```bash
uv run pytest
```

## License

MIT — see `LICENSE`.