Skip to main content
Glama
danilobrando

publish-linkedin

by danilobrando
README.md
# publish-linkedin

Servidor MCP mínimo para publicar en LinkedIn desde tu Second Brain.

Siete herramientas. ~1.100 líneas que puedes leer completas. API oficial de
LinkedIn (OAuth 2.0, scope `w_member_social`) — **sin cookies del navegador**,
que es la zona gris de los términos de servicio.

```
tú: "publica esto en LinkedIn"
Claude: [ensayo] esto es lo que saldría. ¿Confirmas?
tú: "sí"
Claude: ✓ Publicado. linkedin.com/feed/update/urn:li:share:…
```

**El post sale en tu perfil personal**, con tu nombre y tu foto.

## La forma más fácil de instalarlo

Abre Claude Code en una carpeta vacía y pídele esto:

```
Instala https://github.com/danilobrando/publish-linkedin siguiendo su README
y guíame hasta que pueda publicar.
```

Él lee las instrucciones de este archivo, hace lo que le toca a él y te dice
exactamente qué te toca a ti. Si prefieres a mano, están los
[cuatro pasos manuales](#instalación-manual-si-prefieres-no-usar-el-agente) más abajo.

## Por qué existe

Existe `southleft/linkedin-mcp`: 87 herramientas, MIT, buen trabajo. Se
descartó por tres razones concretas:

| | |
|---|---|
| Mantenimiento | Último commit 25-mar-2026 · `Development Status :: 3 - Alpha` |
| Superficie | 87 herramientas para usar 3 |
| Riesgo de ToS | Depende de `linkedin-api` (scraping de cookies) para media API |

La regla es la de siempre: **si necesitas tres herramientas, escribe tres
herramientas.** Un repo que no puedes leer completo es un repo en el que
confías a ciegas.

---

# 🤖 Si eres Claude Code (o cualquier agente): lee esto primero

El usuario te pidió instalar este repo. **Guíalo hasta que publique**, no solo hasta que
instale. Sigue estos pasos en orden y no te saltes ninguna verificación.

## Reglas que no puedes romper

| | |
|---|---|
| ❌ **Nunca** corras `auth.py credenciales` | Usa `input()` y `getpass()`. Se te cuelga la terminal. **Lo corre el usuario.** |
| ❌ **Nunca** corras `auth.py login` | Abre el navegador y espera un clic humano. **Lo corre el usuario.** |
| ❌ **Nunca** pidas ni recibas el Client Secret por el chat | Va tecleado directo en la terminal del usuario. Si te lo pega, dile que lo rote. |
| ✅ Sí corres | `git clone`, crear el entorno, instalar dependencias, `claude mcp add`, `auth.py estado` |

**Cuando llegues a un paso del usuario:** dale el comando exacto **con ruta absoluta**,
dile que lo corra **en su propia terminal**, y **espera** a que te diga que terminó. No
sigas de largo.

**Antes de empezar, avísale:** *"te voy a pedir permiso para correr `git` y `pip`; apruébalos
o no puedo instalar nada."* Sin eso, el primer comando se bloquea y el usuario no entiende
por qué.

## Paso 1 — Instalar *(lo haces tú)*

Detecta el sistema operativo y usa la rama correcta.

**macOS / Linux:**
```bash
git clone https://github.com/danilobrando/publish-linkedin.git
cd publish-linkedin
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
```

**Windows:**
```
git clone https://github.com/danilobrando/publish-linkedin.git
cd publish-linkedin
py -m venv .venv
.venv\Scripts\pip install -r requirements.txt
```

> ⚠️ El entorno **tiene que llamarse `.venv`** y estar dentro del repo. El paso 4 depende
> de esa ruta exacta. No uses `uv`, `conda`, ni un entorno global aunque estén disponibles.

> ⚠️ **Clona dentro de la carpeta donde el usuario te abrió**, no en `/tmp` ni en el home.
> Apenas termines, corre `pwd` dentro del repo y **dile al usuario la ruta absoluta**.
> Desde ahí, **todo comando que le entregues al usuario lleva la ruta absoluta completa**
> — él va a correrlos en otra terminal, parada en otro lado. Un `cd publish-linkedin`
> relativo es la causa #1 de que esto falle.

**Verifica antes de seguir** (macOS/Linux · en Windows `.venv\Scripts\python`):
```bash
.venv/bin/python -c "import mcp, requests; print('OK')"
```
Si no imprime `OK`, arregla eso antes de continuar.

## Paso 2 — Credenciales *(lo hace el usuario)*

**Lo normal y lo recomendado es que el usuario cree su propia app.** Es suya, no depende
de nadie, y no se le vence cuando alguien más rote un secreto. Empieza siempre por ahí.

Dile:

> Vas a crear tu propia aplicación de LinkedIn. Toma unos 10 minutos y te queda tuya para
> siempre. Necesitas administrar una **Página de LinkedIn** — si no tienes una, la creas
> gratis en el momento; sirve una página de tu empresa, de tu marca personal, o una de
> prueba.

Llévalo paso a paso por la sección [Crear tu propia app](#crear-tu-propia-app) de este
mismo archivo. **No la resumas: acompáñalo por cada pestaña** y espera confirmación en cada
una. Los dos errores que más pasan son pedir el producto equivocado y escribir mal la
Redirect URL.

Cuando tenga Client ID y Client Secret, sigue abajo.

### Plan B — credenciales prestadas

**Solo** si el usuario no puede crear la app ahora (no administra ninguna página, LinkedIn
no le deja, o se quedó sin tiempo en una clase en vivo). Dile que le pida al instructor su
Client ID y Client Secret, y explícale por qué no es riesgoso:

> Son las credenciales de una **empresa de prueba**, creada justamente para esto — no de
> una compañía real. La app solo sirve para pedir permiso: **el token que se genera es
> tuyo y solo publica en tu perfil.** Nadie puede publicar por ti, ni tú por nadie.

Adviértele también que **es prestado**: si el instructor rota el secreto, tiene que volver
a hacer el paso 2 con credenciales propias. Por eso el camino bueno es el de arriba.

### 2c · Guardar las credenciales — los DOS caminos pasan por aquí

Cuando el usuario ya tenga Client ID y Client Secret —propias o prestadas—, dile que corra
**en su terminal**, dentro de la carpeta del repo:

```bash
.venv/bin/python auth.py credenciales
```

Le va a pedir **Client ID** (se ve al escribir) y **Client Secret** (no se ve, es normal).

**Espera a que te confirme.** Después verifica tú:
```bash
.venv/bin/python auth.py estado
```

| Lo que imprime | Qué significa |
|---|---|
| `✗ Todavía no hay credenciales` | El paso 2c no se completó. **No sigas.** |
| `✓ Credenciales de la app guardadas` + `✗ Falta autenticarte` | Correcto. Sigue al paso 3. |

El código de salida también sirve: `2` si falta algo, `0` si todo está listo.

## Paso 3 — Autorizar *(lo hace el usuario)*

Dile que corra **en su terminal**:

```bash
.venv/bin/python auth.py login
```

Adviértele **antes** de que lo corra:
- Se le abre el navegador con su sesión de LinkedIn. Tiene que darle **Allow**.
- Tiene 2 minutos. Si se pasa, simplemente lo vuelve a correr.

**Espera.** Después verifica tú:
```bash
.venv/bin/python auth.py estado
```
Tiene que salir **el nombre del usuario** y una fecha de vencimiento (~60 días). Si sale
un error, búscalo en la tabla [Cuando algo falle](#cuando-algo-falle) de este archivo y
resuélvelo antes de seguir.

## Paso 4 — Conectar el servidor *(lo haces tú)*

Desde la carpeta del repo:

```bash
claude mcp add publish-linkedin --scope user \
  -e PYTHONPATH=$PWD -- $PWD/.venv/bin/python $PWD/server.py
```

En **Windows** usa rutas absolutas explícitas en vez de `$PWD` (PowerShell: `$PWD` sirve;
`cmd` no). Verifica con `claude mcp list` que aparezca `publish-linkedin ✔ Connected`.

Después dile al usuario, con estas palabras:

> **Cierra y vuelve a abrir Claude Code.** Los servidores MCP se cargan al arrancar. Si no
> reinicias, las herramientas no aparecen y vas a creer que algo salió mal.

## Paso 5 — Primera publicación *(después del reinicio)*

Ya reiniciado, verifica con la herramienta `linkedin_quien_soy`. Debe devolver el nombre
del usuario.

Después dile:

> Pídeme que publique algo. **La primera vez no voy a publicar**: te muestro un ensayo con
> el texto exacto que saldría. Solo publico cuando tú confirmes.

Cuando te pida publicar:
1. Llama `linkedin_publicar` **sin** `confirmar` (o con `confirmar=False`). Muéstrale el ensayo completo.
2. **Espera su confirmación explícita.** No la asumas, no la interpretes de un «dale» ambiguo.
3. Solo entonces vuelve a llamar con `confirmar=True` y dale la URL.

Si se arrepiente, `linkedin_borrar` con el URN que devolviste.

## Si algo falla en cualquier paso

Busca el síntoma en la tabla [Cuando algo falle](#cuando-algo-falle) de este archivo antes
de improvisar. Si no está ahí, dile al usuario qué falló en una frase, sin jerga, y qué
vas a intentar.

---

# Instalación *(manual, si prefieres no usar el agente)*

Cuatro pasos. Solo necesitas **Python 3.11 o más nuevo**.

## 1 · Clonar e instalar

```bash
git clone https://github.com/danilobrando/publish-linkedin.git
cd publish-linkedin
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
```

En **Windows**, las dos últimas líneas son:

```
py -m venv .venv
.venv\Scripts\pip install -r requirements.txt
```

> Si ya tienes [uv](https://github.com/astral-sh/uv), sirve igual y es más rápido:
> `uv venv --python 3.12 && uv pip install -r requirements.txt`.
> **No hace falta instalarlo solo para esto** — probado con `venv` y `pip` normales.

## 2 · Guardar las credenciales de la app

```bash
.venv/bin/python auth.py credenciales
```

*(en Windows: `.venv\Scripts\python auth.py credenciales` — y así en todos los que siguen)*

Te pide un **Client ID** y un **Client Secret**, que identifican a la
aplicación que pide permiso — no a ti.

> 🎓 **Lo recomendado es crear tu propia app** — ver
> [Crear tu propia app](#crear-tu-propia-app). Toma ~10 minutos y queda tuya.
>
> **Si estás en un curso y no alcanzas:** pide esas dos credenciales a quien lo dicta.
> Una misma aplicación puede servir a todo el salón: cada persona autoriza
> por su lado y publica en su propio perfil. **No van en este repo.**
>
> **Si vas por tu cuenta:** necesitas crear tu propia app.
> Ver [Crear tu propia app](#crear-tu-propia-app) abajo.

Se piden por teclado a propósito: pasarlas como argumento las dejaría en el
historial del shell.

- **macOS** → van al Keychain.
- **Windows y Linux** → a `~/.config/publish-linkedin/`, con permisos 0600.

## 3 · Autenticarte

```bash
.venv/bin/python auth.py login
```

Se abre el navegador **con tu sesión de LinkedIn**. Le das **Allow**. Eso
crea un token que es tuyo y solo publica en tu perfil.

```bash
.venv/bin/python auth.py estado
```

Debe decir tu nombre y hasta cuándo dura el token (~60 días).

> ⚠️ **El token vence y no avisa.** Cuando falle dentro de dos meses, el
> mensaje te va a decir la fecha exacta y qué correr. No es un bug.

## 4 · Conectarlo a Claude Code

```bash
claude mcp add publish-linkedin --scope user \
  -e PYTHONPATH=$PWD -- $PWD/.venv/bin/python $PWD/server.py
```

> ⚠️ **Cierra y vuelve a abrir Claude Code.** Los servidores MCP se cargan al
> arrancar; si no reinicias, no aparecen y vas a creer que te equivocaste.

Verifica pidiéndole a Claude: *"¿cuál es mi estado de LinkedIn?"*

---

## Las cuatro herramientas

| Herramienta | Qué hace |
|---|---|
| `linkedin_estado` | ¿Hay token? ¿Hasta cuándo? ¿Qué permisos? |
| `linkedin_quien_soy` | Confirma contra LinkedIn en qué cuenta se publicaría |
| `linkedin_publicar` | Publica, con o sin imagen o PDF — **solo si `confirmar=True`** |
| `linkedin_borrar` | Borra un post — **solo si `confirmar=True`** |
| `linkedin_doctor` | Diagnostica todo. Pásale `reparar=True` para que arregle lo que pueda |
| `linkedin_menciones` | A quién puedes etiquetar por nombre |
| `linkedin_mencion_guardar` | Agrega a alguien al directorio, verificando su URN |

### Adjuntar una imagen o un PDF

```
tú: "publica esto con la imagen ~/graficos/resultados.png"
```

Un solo adjunto por post. Sirven `.jpg` `.png` `.gif` (hasta 10 MB) y
`.pdf` `.pptx` `.docx` (hasta 100 MB). El PDF sale en el feed como documento
navegable, no como enlace.

El parámetro `titulo` cambia de significado según el tipo, porque LinkedIn los
usa distinto:

| Adjunto | `titulo` es | Por qué importa |
|---|---|---|
| Imagen | el **texto alternativo** | Accesibilidad: lo lee un lector de pantalla |
| Documento | el **nombre visible** | Es lo que la gente ve en el feed antes de abrirlo |

El archivo se valida **antes** de subir nada —que exista, que la extensión
sirva, que no esté vacío, que no pase del límite— y el ensayo te dice qué
adjunto saldría:

```
⏸  ENSAYO — no se publicó nada.
Con adjunto → documento: informe.pdf (2.400 KB, application/pdf)
```

Y el adjunto entra en la huella de idempotencia: el mismo texto con otra imagen
es otro post, y bloquearlo sería un falso positivo.

### Mencionar a alguien (etiquetar)

```
tú: "publica esto agradeciéndole a @Andrés Caicedo"
```

Escribes `@Nombre` y el sistema lo convierte en una etiqueta real, que notifica
a la persona. Funciona igual con páginas de empresa.

**Pero hay un paso manual la primera vez, y no se puede evitar.**

LinkedIn no le deja a esta app averiguar la URN de nadie. Probado contra la API:

| Endpoint | Respuesta |
|---|---|
| `/v2/people?q=search` | `404` — no existe para esta app |
| `/v2/people/(vanityName:…)` | `403` ACCESS_DENIED, partner API |
| `/v2/connections` | `403` |
| `/rest/organizations` | `403` |
| `/v2/userinfo` (tú mismo) | `200` ✓ — lo único accesible |

Resolver URNs ajenas exige el **Marketing Developer Platform**, que es una
solicitud de negocio con revisión humana. No es un permiso que se active.

Así que la URN se captura a mano, **una vez por persona**:

1. Abre el perfil de la persona en LinkedIn, con tu sesión iniciada.
2. Abre las herramientas de desarrollador del navegador (`⌥⌘I` en Mac) y pega
   esto en la consola:
   ```js
   document.body.innerHTML.match(/urn:li:fsd_profile:([A-Za-z0-9_-]+)/)[1]
   ```
3. Eso devuelve algo como `ACoAAB1cD…`. La URN es `urn:li:person:` + eso.
4. Guárdala — y el sistema **la verifica contra LinkedIn antes de aceptarla**:

```
tú: "guarda a Andrés Caicedo con urn:li:person:ACoAAB1cD..."
→ ✓ Guardado (verificada con LinkedIn). Ahora escribe «@Andrés Caicedo».
```

La verificación usa un **borrador** que nunca sale al feed: LinkedIn valida las
menciones del lado del servidor incluso en estado `DRAFT`, así que se crea uno,
se lee la respuesta y se borra. Una URN inventada responde
`400 INVALID_MENTION_PERSON_URN_ID`; una real, `201`.

Es tedioso la primera vez y gratis todas las siguientes: siempre mencionas a las
mismas veinte personas. El directorio vive en
`~/.config/publish-linkedin/menciones.json` (permisos 0600).

El ensayo te dice a quién vas a etiquetar y a quién **no**:

```
Menciona a → Andrés Caicedo (ACoAAB1cD), Tribu iA (99887)
⚠️  Con @ pero SIN etiquetar (no están en tu directorio): Pedro Perez
    Van a salir como texto plano.
```

### El freno de mano

`linkedin_publicar` **no publica por defecto**. Sin `confirmar=True` hace un
ensayo y devuelve el texto exacto que saldría, el conteo de caracteres y cómo
queda tras escapar los reservados del formato *little text* de LinkedIn.

Publicar es irreversible y es tu nombre. Un sistema autónomo sin freno no es
autonomía — es un accidente esperando su turno.

## Cuando algo no funcione: el doctor

```bash
.venv/bin/python doctor.py          # 10 chequeos, cada fallo dice qué hacer
.venv/bin/python doctor.py fix      # repara locks rancios y permisos
.venv/bin/python doctor.py --quiet  # solo el veredicto (para scripts)
```

Códigos de salida: `0` todo bien · `1` hay un aviso · `2` algo roto · `64` mal uso.

También está como herramienta MCP: pídele a Claude *"diagnostica mi LinkedIn"*.

### Que te avise antes de que se venza el token

El token dura ~60 días y **LinkedIn no avisa**. Esta app no recibe
`refresh_token` (solo se los dan a apps aprobadas), así que hay que volver a
autorizar. Para no enterarte el día que necesitas publicar:

```bash
./instalar-vigilante.sh        # corre el doctor a diario a las 9:15 (macOS)
./instalar-vigilante.sh quitar
```

En Linux, la misma idea con cron: `15 9 * * * /ruta/al/repo/vigilante.sh`

## Probar sin publicar

```bash
PUBLISH_LINKEDIN_SIMULACRO=1 ...
```

Con esa variable, `publicar` y `borrar` **nunca tocan la red**. La suite la
activa sola:

```bash
.venv/bin/python prueba.py               # 46 pruebas, ninguna contacta a LinkedIn
.venv/bin/python prueba_concurrencia.py  # 8 procesos reales peleando por el lock
```

La segunda va aparte porque necesita procesos de verdad. Verifica que el lock
sea atómico — y está calibrada para **fallar** con la versión anterior, que
dejaba entrar hasta 8 publicadores a la vez.

> Existe por un incidente real: el 31-ago-2026, dos corridas automatizadas
> publicaron de verdad en un perfil real. Redirigir el directorio de datos no
> basta — el token vive en el Keychain y sigue siendo válido.

## Cómo se evita publicar dos veces

Es el fallo más caro de un publicador: no fallar, sino **duplicar en público**.
Hay cuatro controles, y los cuatro fueron necesarios:

| Control | Qué previene |
|---|---|
| Huella del contenido, ventana 24h | Republicar el mismo texto |
| El chequeo corre **dentro** del lock | Que dos procesos lo pasen a la vez |
| Lock atómico (`O_CREAT\|O_EXCL`) | Que dos procesos tomen el lock a la vez |
| Se registra `INTENTO` **antes** de llamar | Que un timeout deje el post vivo sin rastro |

Y ante un `5xx` de LinkedIn **no se reintenta a propósito**: un 5xx no dice si
el post se creó o no. El mensaje te lo dice y te manda a revisar tu perfil.

Si borras un post, su huella se libera y puedes volver a publicarlo corregido.

## Seguridad

- Secretos y token en el Keychain (macOS) o en archivos 0600 (Windows/Linux).
  **Nunca en un archivo de configuración legible.**
- El `state` de OAuth se valida al volver del navegador (anti-CSRF).
- Toda publicación —ensayo, fallo o real— queda en el diario de auditoría:
  `~/.config/publish-linkedin/publicaciones.log` (override: `PUBLISH_LINKEDIN_LOG`).

### Si compartes una app con varias personas

Es válido y es como funciona OAuth: la app pide permiso, cada persona lo
concede por separado, y cada token solo sirve para quien lo autorizó. **Nadie
puede publicar en el perfil de otro.** Dicho eso, dos cosas que conviene saber:

- El Client Secret **es una contraseña de la app**. Quien lo administra debe
  poder rotarlo (pestaña `Auth` del portal, un clic) y hacerlo cuando el grupo
  deje de necesitarlo.
- LinkedIn limita por app además de por persona. Si mucha gente publica en el
  mismo minuto, van a ver un `429`. El mensaje lo explica y dice cuánto esperar.

## Cuando algo falle

| Síntoma | Qué pasó |
|---|---|
| `Todavía no hay credenciales` | Falta el paso 2 |
| `No hay token` / `El token venció` | Corre `auth.py login` otra vez |
| El navegador dice `redirect_uri` inválido | La app no tiene registrada `http://localhost:8765/callback` |
| `429` | Límite de LinkedIn. Espera lo que diga el mensaje |
| `401` / `403` al publicar | Token vencido, o falta el producto `Share on LinkedIn` en la app |
| `426 NONEXISTENT_VERSION` | LinkedIn retiró la versión de API. El código la renegocia solo y reintenta una vez |
| Claude no ve las herramientas | No reiniciaste Claude Code después del paso 4 |

---

## Crear tu propia app

**Este es el camino recomendado.** Toma ~10 minutos y la app queda tuya.

### Primero: la Página

LinkedIn no deja crear una app sin asociarle una **Página**. Si no administras ninguna,
créala gratis en **[linkedin.com/company/setup/new](https://www.linkedin.com/company/setup/new)**.
Sirve tu empresa, tu marca personal, o una página de prueba — da igual: **la página no es
donde sale el post.** El post sale en tu perfil personal. La página solo figura como
"editor" de la app en el portal. Es papeleo.

### Después: la app

Ve a **[linkedin.com/developers/apps](https://www.linkedin.com/developers/apps)**
→ *Create app*. Asóciala a tu página. Un *super admin* de esa página tiene que aprobar la
verificación con un enlace — si la página es tuya, eres tú y es un clic.

**Pestaña `Products`** — pide estos dos. Ambos son **aprobación instantánea**:

| Producto | Para qué |
|---|---|
| `Share on LinkedIn` | Publicar. Es el que importa. |
| `Sign In with LinkedIn using OpenID Connect` | Saber quién eres al autenticar. |

No pidas *Community Management API* ni *Ad Library*: esos sí requieren revisión
humana de LinkedIn y tardan días. **No los necesitas para publicar.**

**Pestaña `Auth`**:

1. Copia el **Client ID** y el **Client Secret**.
2. En *OAuth 2.0 settings → Redirect URLs*, agrega exactamente:
   ```
   http://localhost:8765/callback
   ```
   Sin `https`, sin barra al final, con ese puerto. Un carácter de más y el
   login falla con un error que no explica nada.

## Archivos

| Archivo | Qué es |
|---|---|
| `linkedin.py` | Cliente HTTP + almacén de secretos + escapado |
| `medios.py` | Subida de imágenes y documentos (validar → subir → adjuntar) |
| `menciones.py` | Directorio nombre→URN, expansión de `@Nombre` y verificación |
| `auth.py` | Flujo OAuth (`credenciales` · `login` · `estado`) |
| `server.py` | Servidor MCP con las cuatro herramientas |

## Estado y pendientes

Versión `0.1.0`. Ver `HARDENING.md` para el detalle.

Lo que falta y conviene saber antes de confiar en esto:

- **Windows y Linux están escritos pero sin probar.** El código los contempla;
  nadie los ha corrido de verdad.
- **No hay `refresh_token`**: reautorizar cada ~60 días es obligatorio.
- Publicar sin humano **no está soportado a propósito**. `confirmar=True` es
  siempre de una persona.

MIT.