jira-lite-mcp
# jira-lite-mcp
Servidor MCP para Jira Cloud, pensado para trabajar con Claude Code.
No pretende cubrir toda la API de Jira: expone pocas herramientas de alto valor, con
respuestas legibles para un modelo en lugar de las respuestas crudas de la API.
**Es genérico.** No conoce proyectos, épicas ni campos personalizados concretos: los resuelve
contra la instancia a la que se conecta. Los nombres de campo se indican como se ven
(`"Criterios de aceptación"`) y el servidor los traduce a su identificador real.
Lo que cambia en cada versión está en [`CHANGELOG.md`](CHANGELOG.md), con lo que conviene
revisar en las guías de cada equipo.
---
## Requisitos
- Node 20 o superior
- Una cuenta de Jira Cloud con [token de API](https://id.atlassian.com/manage-profile/security/api-tokens)
---
## Instalación
```bash
npm install
cp .env.example .env # y rellenar
npm run build
```
`.env`:
```
JIRA_URL=https://tu-organizacion.atlassian.net
JIRA_EMAIL=tu-correo@example.com
JIRA_TOKEN=tu-token
```
### Registrar en Claude Code
```bash
claude mcp add jira-lite --scope user -- node /ruta/absoluta/a/jira-lite-mcp/dist/server.js
```
`--scope user` lo deja disponible en todos los proyectos, con las credenciales en el `.env` de
este repositorio.
Comprobar que responde:
```bash
claude mcp get jira-lite
```
> Tras cambiar el código hay que ejecutar `npm run build` **y reiniciar la sesión**: el
> cliente arranca el servidor al abrirla y mantiene ese proceso mientras dura, así que hasta
> entonces sigue sirviendo el código anterior. `/clear` no basta.
>
> La herramienta `ping` indica qué código está en ejecución:
>
> ```json
> { "status": "ok", "version": "1.2.0", "built": "2026-07-20T02:16:44.020Z" }
> ```
>
> Si `built` es anterior a la última compilación, la sesión está sirviendo código antiguo.
> Es la forma de distinguir una capacidad que no existe de una que no está desplegada.
### Registrar en un proyecto concreto (`.mcp.json`)
Para dejarlo declarado en un repositorio y compartirlo con el equipo, se usa el scope
`project`, que escribe un `.mcp.json` en su raíz:
```bash
claude mcp add --scope project jira-lite -- node /ruta/absoluta/a/jira-lite-mcp/dist/server.js
```
El fichero resultante se commitea. Como cada persona clonará este servidor en una ubicación
distinta, la ruta absoluta conviene sustituirla por una variable con valor por defecto:
```json
{
"mcpServers": {
"jira-lite": {
"command": "node",
"args": ["${JIRA_MCP_PATH:-/ruta/por/defecto}/jira-lite-mcp/dist/server.js"]
}
}
}
```
Cada miembro define `JIRA_MCP_PATH` en su shell y mantiene su propio `.env` en este
repositorio. No hace falta declarar las credenciales en `.mcp.json`.
Si se prefiere pasarlas desde el cliente, se añaden como variables del servidor:
```json
"env": {
"JIRA_URL": "${JIRA_URL}",
"JIRA_EMAIL": "${JIRA_EMAIL}",
"JIRA_TOKEN": "${JIRA_TOKEN}"
}
```
> ⚠️ Nunca escribir el token literal: `.mcp.json` se versiona.
>
> Un cliente que no encuentre la variable **entrega el marcador sin sustituir** en lugar de
> omitirlo. El servidor detecta ese caso y recurre al `.env`, en vez de intentar autenticarse
> con la cadena `${JIRA_TOKEN}` y devolver un error de credenciales sin relación aparente.
La primera vez que alguien abra el proyecto, Claude Code pedirá aprobar los servidores
declarados. `claude mcp reset-project-choices` restablece esa decisión.
Un servidor con el mismo nombre en varios ámbitos se resuelve por precedencia —local, luego
proyecto, luego usuario— y se usa la definición completa del que gane, sin combinar campos.
---
## Herramientas
### Lectura
| Herramienta | Para qué |
|---|---|
| `jira_my_work` | Issues asignados y pendientes. *«¿Qué tengo pendiente en Jira?»* |
| `jira_list_projects` | Proyectos visibles, con su clave. *«¿Qué proyectos hay?»* |
| `jira_project_summary` | Estado de un proyecto: abiertos, reparto por estado, tipo y prioridad, sin asignar y estancados |
| `jira_explain_issue` | Un issue con todo su contexto: padre, subtareas, enlaces, comentarios y transiciones posibles |
| `jira_get_issue` | Datos básicos de un issue |
| `jira_search` | Búsqueda por JQL |
| `jira_issue_fields` | Tipos de issue de un proyecto y campos que admite cada uno al crearlo |
| `jira_get_worklog` | Tiempo registrado en un issue: estimación, total y desglose |
`jira_explain_issue`, `jira_get_issue` y `jira_search` aceptan `fields`: los campos
personalizados a incluir, por su nombre visible o su identificador.
### Escritura
| Herramienta | Para qué |
|---|---|
| `jira_claim_issue` | **Tomar una tarea**: comprueba que está libre, se la asigna y relee para confirmarlo |
| `jira_create_issue` | Crear un issue, validando los campos antes de enviarlos |
| `jira_update_issue` | Modificar campos de un issue |
| `jira_transition_issue` | Cambiar de estado, opcionalmente con comentario |
| `jira_link_issues` | Enlazar dos issues |
| `jira_add_comment` | Comentar |
| `jira_add_worklog` | Registrar tiempo |
| `jira_create_sprint` | Crear un sprint en el tablero scrum del proyecto |
| `jira_move_to_sprint` | Mover issues a un sprint |
| `jira_delete` | Eliminar un comentario, un registro de tiempo o un enlace |
---
## Notas de uso
**Tomar una tarea se hace con `jira_claim_issue`, no asignándose a pelo.** La herramienta lee
el issue, se detiene si ya tiene dueño, si está cerrada o si está sin asignar pero ya empezada,
y solo entonces escribe la asignación y **vuelve a leerla** para confirmar que quedó a tu
nombre. Jira no ofrece escritura condicional sobre la persona asignada: si dos personas se
asignan a la vez, la segunda escritura gana y Jira **no avisa a la primera**, de modo que las
dos se van convencidas de tenerla y el trabajo se hace dos veces. Releyendo, quien perdió lee
el nombre de la otra y se retira.
⚠️ **No es una toma atómica.** Entre la escritura y la relectura queda una ventana en la que
las dos pueden leerse a sí mismas; lo que se consigue es reducirla a dos peticiones en vez de
las horas que lleva desfasada una lista generada por la mañana. Cerrarla del todo exigiría algo
que Jira no da, así que no se promete.
El estado se cambia con `to`, y **solo si se ganó la asignación**: transicionar antes dejaría
«En curso» una tarea que acaba de ser de otra persona. Si la transición falla, la toma no se
niega —la asignación ya está escrita y es ella la que reserva la tarea—: la respuesta lleva
`transitionError` con el motivo y los estados posibles.
**Los campos se indican por su nombre.** `jira_create_issue` y `jira_update_issue` aceptan
`customFields` con el nombre visible del campo, y el servidor resuelve el identificador y el
formato correctos contra la instancia:
```json
{ "customFields": { "Criterios de aceptación": "- [ ] Primero\n- [ ] Segundo" } }
```
**Un parámetro que no existe da error, y el error dice cómo se llama aquí.** Los nombres de
otros servidores de Jira —`assigneeAccountId`, `timetracking`, `additional_fields`,
`projectKey`— se descartaban en silencio y el issue nacía sin lo que se le había puesto. Ahora
la llamada se rechaza antes de tocar Jira, nombrando el equivalente: la persona asignada es
`assignee`, la estimación `originalEstimate` —una cadena suelta, no un objeto— y los campos
personalizados `customFields`.
**El alta devuelve el asignado y la estimación tal como quedaron.** Ninguno de los dos vuelve
en la respuesta de creación de Jira, y los dos pueden quedar distintos de lo pedido sin que
nada falle: la estimación en días se interpreta según la jornada del sitio y una asignación que
el proyecto no admite se descarta. Se releen del issue creado para poder contrastarlos.
**Un nombre repetido se rechaza en vez de devolver vacío.** Una instancia puede tener varios
campos con el mismo nombre visible, uno por proyecto. Al leer un issue se piden todos los
candidatos y se usa el que aplica a ese proyecto, que es lo normal; si aplica más de uno, el
error enumera sus identificadores para elegir. Antes se resolvía al primero, que en otro
proyecto no tiene valor: el issue se leía como si el campo estuviera vacío.
**El catálogo de campos se refresca solo.** Se cachea al primer uso, pero un identificador
desconocido lo hace pedirse de nuevo antes de darlo por inexistente. Así un campo creado en
Jira con la sesión ya abierta se puede leer sin reiniciarla.
**Los campos de texto rico son markdown, en las dos direcciones.** Descripciones, comentarios
y campos `textarea` se leen como markdown en lugar de aplanados a texto corrido, y lo que se
escribe se traduce al formato de la API. Cubre listas de tareas —las casillas marcables del
editor—, listas con viñetas y numeradas anidadas, encabezados, citas, bloques de código,
reglas, tablas con la sintaxis de GitHub y, en línea, negrita, cursiva, tachado, código y enlaces.
```json
{ "customFields": { "Criterios de aceptación": "- [ ] Sin marcar\n- [x] Hecho" } }
```
Al leer, ese mismo campo vuelve con su estructura y con el estado de cada casilla, así que una
lista de criterios se distingue de un párrafo suelto. Una línea suelta sigue siendo un párrafo:
los saltos con los que se escribió el texto se conservan tal como se ven en Jira.
**Se valida antes de escribir.** Al crear un issue se comprueban los campos contra el esquema
real del proyecto y del tipo. Un payload incorrecto falla en local, sin llegar a la API: Jira
reserva la clave del issue al procesar la petición, y una petición inválida la consume igual.
**Los estados se indican por su nombre.** `jira_transition_issue` acepta el estado de destino
(`"Finalizada"`), el nombre de la transición (`"Listo"`) o su identificador, y resuelve cuál
aplica contra el workflow del issue.
**En JQL los tipos de issue van en inglés.** Un sitio traducido muestra `Historia` o `Error`,
pero `jira_search` necesita `Story` o `Bug`: escribir el nombre traducido devuelve cero
resultados sin dar error. `jira_project_summary` no se ve afectado, porque agrupa por tipo
sobre los issues ya recuperados.
**Campos obligatorios por convención.** Un equipo puede dar por obligatorio un campo que
Jira no marca como tal —y cuya ausencia, por tanto, no señala—. `JIRA_REQUIRED_FIELDS` lo
convierte en un error al crear:
```
JIRA_REQUIRED_FIELDS_LAN=Team # solo en el proyecto LAN
JIRA_REQUIRED_FIELDS=Team # en todos
```
No se rellena nada automáticamente: la creación se rechaza para que el valor lo decida
siempre quien la pide. Con `dryRun` la comprobación se hace igualmente, sin gastar una clave.
En una subtarea el requisito se comprueba **contra su issue padre**, porque hereda de él parte
del contexto. Algunos campos —el equipo asignado, por ejemplo— Jira ni siquiera admite
enviarlos en una subtarea: los rechaza indicando que se heredan. Exigirlos en el payload
haría imposible crear subtareas en un proyecto con esta política.
**No se pueden eliminar issues.** `jira_delete` cubre comentarios, registros de tiempo y
enlaces, pero no issues: borrar uno destruye trabajo registrado junto con sus subtareas y deja
un hueco permanente en la numeración del proyecto. Para retirar un issue de la circulación,
moverlo a un estado final con `jira_transition_issue`.
**Los sprints cuelgan del tablero, no del proyecto.** Y solo los tableros scrum los admiten:
en un proyecto kanban no hay dónde crearlos. `jira_create_sprint` acepta la clave del proyecto
y localiza su tablero scrum; si hay más de uno, el error los enumera con su `boardId` en vez de
elegir por su cuenta.
**Crear un sprint no lo arranca.** Queda en estado `future`. Iniciarlo cierra el anterior y fija
el compromiso del equipo, así que esa decisión se deja en Jira.
```json
{ "project": "LAN", "name": "Sprint 12", "startDate": "2026-09-01", "endDate": "2026-09-15" }
```
Una fecha sin hora se ancla a medianoche UTC: Jira solo muestra el día, y anclarla a la hora
local la desplazaría al día anterior para quien esté al oeste del meridiano.
**Un issue pertenece a un solo sprint.** `jira_move_to_sprint` lo saca del anterior, así que
sirve igual para poblar un sprint nuevo que para reubicar trabajo. La API mueve como mucho 50
issues por petición y aplica cada una entera o ninguna: con más de 50 la respuesta indica
cuáles se movieron y qué lote falló, en lugar de dar por hecho que se movió todo.
**Los campos personalizados hay que pedirlos.** `jira_get_issue`, `jira_explain_issue` y
`jira_search` devuelven un juego fijo de campos; cualquier otro solo viene si se nombra en
`fields`, porque una instancia con decenas de campos por proyecto haría ilegible cada lectura.
```json
{ "issueKey": "LAN-2766", "fields": ["Criterios de aceptación"] }
```
Esto importa más de lo que parece: **la ausencia del campo en la respuesta no significa que el
issue no lo tenga**, y los dos casos se leen igual. Un ticket cuyos criterios de aceptación
viven en su campo se lee, sin `fields`, como un ticket sin criterios. Para modificar un campo
hay que reescribirlo entero, así que leerlo antes —con `fields`— es lo que evita borrar lo que
hubiera puesto a mano.
Cada campo se devuelve bajo **la clave que se pidió**: por identificador, el identificador;
por nombre, tal como se escribió. No bajo el nombre canónico del campo, que en un sitio
traducido depende del idioma. Un issue referenciado —`parent`— se devuelve como `{key, summary}`,
no con el documento entero anidado.
El nombre no identifica: una instancia puede tener varios campos llamados igual, uno por
proyecto. Cuando el nombre es ambiguo se puede pasar el identificador (`["customfield_10064"]`),
que sí es único. En `jira_get_issue` y `jira_explain_issue` un nombre que no se resuelve es un
error; en `jira_search` se informa en `warning` y el resto de resultados se devuelve igual,
porque un issue de otro proyecto no debe tumbar la página entera.
**Las búsquedas no devuelven el total de coincidencias.** El endpoint de Jira pagina y no
informa del total, así que `jira_search` devuelve cuántos issues trae (`count`) y si quedan más
(`hasMore`).
---
## Desarrollo
```bash
npm run dev # servidor en modo watch
npm run build # compilar a dist/
```
Inspeccionar el servidor sin pasar por Claude Code:
```bash
npx @modelcontextprotocol/inspector --cli node dist/server.js --method tools/list
npx @modelcontextprotocol/inspector --cli node dist/server.js \
--method tools/call --tool-name jira_my_work --tool-arg limit=5
```
La arquitectura y las decisiones de diseño están en [`docs/plan/PLAN.md`](docs/plan/PLAN.md).
TDQS
Scored across 15 tools
Each tool has a clearly distinct purpose: ping for health, get/search/create/update/transition for individual issues, explain for full context, link for linking, comment/worklog add/get/delete, and project summary. Even related tools like jira_get_issue and jira_explain_issue are complementary, not overlapping.
Most tools follow the consistent pattern 'jira_verb_noun' (e.g., jira_get_issue, jira_create_issue). The only outlier is 'ping' which lacks the 'jira_' prefix. This minor inconsistency prevents a perfect score.
With 15 tools, the set is well-scoped for a Jira lite server. Each tool covers a distinct operation, and there is no bloat. The count is appropriate for the domain.
The tool surface covers most essential Jira operations: CRUD for issues, transitions, linking, comments, worklogs, and project summary. Minor gaps exist (no project listing, no issue deletion, no worklog update), but these are acceptable for a 'lite' server.