Skip to main content
Glama
jacobenligne

li-mcp-vercel

by jacobenligne
README.md
# li-mcp-vercel

Serveur MCP LinkedIn déployé sur Vercel, avec **proxy OAuth stateless** intégré. Donne
accès au profil personnel (et, après approbation Community Management, aux pages
LinkedIn administrées) depuis Claude ou tout client MCP standard.

## Pourquoi un proxy OAuth?

LinkedIn ne peut pas faire face à un client MCP directement (vérifié en production,
2026-07-27) :

| Exigence MCP (OAuth 2.1) | LinkedIn |
|---|---|
| PKCE (S256) | ❌ non supporté |
| Enregistrement dynamique de client (RFC 7591) | ❌ non supporté |
| Métadonnées AS (RFC 8414) | ❌ 404 — seule la découverte OIDC existe |
| Auth au token endpoint | `client_secret_post` **uniquement** |

Ce serveur est donc **le serveur d'autorisation** aux yeux du client MCP, et parle le
dialecte LinkedIn derrière :

```
Client MCP ──OAuth 2.1 (PKCE + DCR)──▶ ce serveur ──client_secret_post──▶ LinkedIn
```

### Stateless de bout en bout

Aucune base de données, aucun token stocké :

- **client_id** délivré par `/api/oauth/register` = blob HMAC-signé contenant les
  `redirect_uris` du client. `/api/oauth/authorize` vérifie contre le blob lui-même.
- **state** traversant LinkedIn = blob signé portant le contexte du client
  (redirect_uri, state, code_challenge).
- **code d'autorisation** remis au client = blob signé enveloppant le code LinkedIn,
  en attente de la vérification PKCE à `/api/oauth/token`.
- La clé HMAC est dérivée de `LINKEDIN_CLIENT_SECRET` — pas de variable supplémentaire.
  Régénérer le secret invalide simplement les flux en vol.

### Endpoints

| Route | Rôle |
|---|---|
| `/api/mcp` | endpoint MCP (transport HTTP) |
| `/.well-known/oauth-protected-resource` | RFC 9728 — désigne ce serveur comme AS |
| `/.well-known/oauth-authorization-server` | RFC 8414 — annonce PKCE + DCR |
| `/api/oauth/register` | RFC 7591 stateless |
| `/api/oauth/authorize` | valide client + PKCE, redirige vers LinkedIn |
| `/api/oauth/callback` | rappelé par LinkedIn, renvoie le code enveloppé au client |
| `/api/oauth/token` | vérifie PKCE, échange chez LinkedIn en `client_secret_post` |

## Modèle de sécurité

Le serveur ne stocke aucun token. Le token LinkedIn de l'appelant est vérifié à chaque
requête puis retransmis à l'API LinkedIn : un appelant n'atteint que les données que son
compte peut déjà voir. Vérification en trois temps (`lib/auth.ts`) :

1. **Introspection** — `POST /oauth/v2/introspectToken`. Présenter nos propres
   credentials fait office de contrôle d'audience : un token émis pour une autre app
   revient `active: false`.
2. **Scopes** — au moins un scope produit. L'introspection renvoie les scopes séparés
   par des **virgules**, pas des espaces.
3. **Identité** — `/v2/userinfo` fournit le `sub` (pairwise, stable par app), filtré par
   `ALLOWED_SUBS`.

## Pièges LinkedIn (appris en production)

- **Introspection éventuellement cohérente** : un token fraîchement émis peut lire
  `status=revoked` pendant quelques secondes. `/api/oauth/token` retient le token
  jusqu'à confirmation `active`, et la vérification réessaie avant de rejeter — sinon le
  client jette le token au premier 401.
- **Scopes non accordés = « Bummer, something went wrong »** : n'annoncer dans
  `SCOPES_SUPPORTED` que les scopes des produits réellement approuvés. Les scopes org
  (palier 2) n'y entrent qu'après approbation Community Management.
- **Découverte** : seul `https://www.linkedin.com/oauth/.well-known/openid-configuration`
  existe côté LinkedIn — mais le client MCP n'en a plus besoin, il ne voit que ce serveur.

## Installation

### 1. App LinkedIn ([developer.linkedin.com](https://developer.linkedin.com))

- Produits requis : **Sign In with LinkedIn using OpenID Connect** + **Share on LinkedIn**
- *Authorized redirect URLs* : `https://<ton-deploiement>/api/oauth/callback`
  (c'est le **proxy** que LinkedIn rappelle, pas le client MCP)

### 2. Variables d'environnement

| Variable | Rôle |
|---|---|
| `LINKEDIN_CLIENT_ID` | identifiant de l'app (non sensible) |
| `LINKEDIN_CLIENT_SECRET` | secret de l'app — introspection, échange de code, clé HMAC |
| `ALLOWED_SUBS` | `sub` OIDC autorisés, séparés par des virgules |

```bash
vercel env add LINKEDIN_CLIENT_ID production
vercel env add LINKEDIN_CLIENT_SECRET production
vercel env add ALLOWED_SUBS production
```

**Amorçage de `ALLOWED_SUBS`** : laisser vide, se connecter une fois, relever le `sub`
journalisé (`[auth] ALLOWED_SUBS vide — Amorcer avec: …`), le renseigner, redéployer.
Ne pas laisser vide en régime permanent — le contrôle d'identité est alors désactivé.

### 3. Déployer

```bash
npm install
vercel --prod
```

### 4. Connecter depuis Claude

Ajouter un connecteur personnalisé avec la seule URL :

```
https://<ton-deploiement>/api/mcp
```

**Ne pas saisir de client id/secret** — l'enregistrement dynamique s'en charge.

## Outils

### Palier 1 — self-serve

| Outil | Rôle |
|---|---|
| `li_token_status` | validité, expiration, scopes réels du token |
| `li_me` | profil du membre authentifié |

### Palier 2 — après approbation Community Management

Non encore implémenté (posts, pages, statistiques d'organisation).

## Développement local

```bash
npm install
cp .env.local.example .env.local   # remplir LINKEDIN_CLIENT_ID / LINKEDIN_CLIENT_SECRET
npm run dev                        # http://localhost:3000, endpoint /api/mcp
```

Pour tester le flux OAuth complet en local, ajouter aussi dans l'app LinkedIn
(*Authorized redirect URLs*) :

```
http://localhost:3000/api/oauth/callback
```

Puis brancher un client MCP sur `http://localhost:3000/api/mcp` :

```bash
# Claude Code
claude mcp add --transport http linkedin http://localhost:3000/api/mcp

# ou l'inspecteur MCP
npx @modelcontextprotocol/inspector http://localhost:3000/api/mcp
```

Laisser `ALLOWED_SUBS` vide dans `.env.local` au premier essai (amorçage), puis le
renseigner avec le `sub` journalisé dans la console.

Vérifications rapides sans client :

```bash
curl -s localhost:3000/.well-known/oauth-authorization-server | jq
curl -s -X POST localhost:3000/api/oauth/register \
  -H "Content-Type: application/json" \
  -d '{"redirect_uris":["http://localhost:6274/oauth/callback"]}' | jq
npm run build   # avant tout déploiement
```