@itzjoordi/jira-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env-example ADDED
@@ -0,0 +1,3 @@
1
+ JIRA_BASE_URL=https://tu-org.atlassian.net
2
+ JIRA_EMAIL=tu-email@empresa.com
3
+ JIRA_API_TOKEN=TU_TOKEN_AQUI
package/.eslintrc.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "env": {
3
+ "node": true,
4
+ "es2022": true
5
+ },
6
+ "extends": ["standard", "prettier"],
7
+ "rules": {
8
+ "no-unused-vars": [
9
+ "warn",
10
+ {
11
+ "argsIgnorePattern": "^_"
12
+ }
13
+ ],
14
+ "import/no-anonymous-default-export": "off",
15
+ "no-console": "off"
16
+ }
17
+ }
@@ -0,0 +1,3 @@
1
+ node_modules/
2
+ .vscode/
3
+ .env
@@ -0,0 +1,8 @@
1
+ {
2
+ "semi": true,
3
+ "singleQuote": true,
4
+ "trailingComma": "all",
5
+ "printWidth": 100,
6
+ "tabWidth": 2,
7
+ "arrowParens": "always"
8
+ }
package/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # 🧩 MCP Jira Mercadona para VS Code + GitHub Copilot
2
+
3
+ Este proyecto proporciona un **servidor MCP** que permite interactuar con **Jira Mercadona** directamente desde **VS Code** usando **GitHub Copilot Chat**.
4
+
5
+ Incluye herramientas para:
6
+
7
+ - Filtrar issues de forma natural: “mis tareas pendientes”, “backlog del proyecto”, “tareas en curso”, etc.
8
+ - Mover tareas entre estados (workflow transition)
9
+
10
+ ## 🚀 Requisitos
11
+
12
+ - Node.js 18+
13
+ - VS Code 1.102+
14
+ - Extensión GitHub Copilot Chat → <https://code.visualstudio.com/docs/copilot/overview>
15
+ - API Token → <https://id.atlassian.com/manage-profile/security/api-tokens>
16
+
17
+ ## 📦 Instalación
18
+
19
+ ```bash
20
+ git clone <URL-del-repo>
21
+ cd jira-mcp-test
22
+ npm install
23
+ ```
24
+
25
+ ## 🔑 Configuración
26
+
27
+ ```bash
28
+ cp .env.example .env
29
+ ```
30
+
31
+ Editar `.env`:
32
+
33
+ ```
34
+ JIRA_BASE_URL=https://<tu-org>.atlassian.net
35
+ JIRA_EMAIL=tu-email@empresa.com
36
+ JIRA_API_TOKEN=<tu-api-token>
37
+ ```
38
+
39
+ ## 🧩 Activación MCP
40
+
41
+ En VS Code:
42
+
43
+ ```
44
+ Reiniciar completamente VS Code
45
+ Command Palette > MCP: List Servers
46
+ ```
47
+
48
+ Debe aparecer `jiraMcp`.
49
+
50
+ ## 💬 Uso (Copilot Chat)
51
+
52
+ Ejemplos:
53
+
54
+ ```
55
+ Devuelveme todas mis tareas asignadas en curso
56
+ ```
57
+
58
+ ```
59
+ Dime el backlog del proyecto XXXX
60
+ ```
61
+
62
+ ```
63
+ Cambia el estado de la tarea XXXX-#### a VALIDADA
64
+ ```
65
+
66
+ ## 👷 Estructura
67
+
68
+ - server.mjs
69
+ - jiraApi.mjs
70
+ - jiraClient.mjs
71
+ - .vscode/mcp.json
72
+ - .env.example
73
+ - package.json
74
+
75
+ ## 🧯 Problemas comunes
76
+
77
+ - 401/403 → token incorrecto
78
+ - MCP no aparece → reiniciar VS Code
@@ -0,0 +1,12 @@
1
+ import js from '@eslint/js';
2
+ import globals from 'globals';
3
+ import { defineConfig } from 'eslint/config';
4
+
5
+ export default defineConfig([
6
+ {
7
+ files: ['**/*.{js,mjs,cjs}'],
8
+ plugins: { js },
9
+ extends: ['js/recommended'],
10
+ languageOptions: { globals: globals.browser },
11
+ },
12
+ ]);
package/jiraApi.mjs ADDED
@@ -0,0 +1,61 @@
1
+ // jiraApi.mjs
2
+ import { jiraRequest } from './jiraClient.mjs';
3
+
4
+ /**
5
+ * Buscar issues usando JQL (API nueva /search/jql)
6
+ */
7
+ export async function searchIssues(jql, maxResults = 20) {
8
+ const body = {
9
+ jql,
10
+ maxResults,
11
+ fields: ['summary', 'status', 'assignee', 'priority'],
12
+ };
13
+
14
+ const data = await jiraRequest('/rest/api/3/search/jql', {
15
+ method: 'POST',
16
+ body: JSON.stringify(body),
17
+ });
18
+
19
+ const issues = data.issues ?? [];
20
+
21
+ return issues.map((issue) => ({
22
+ key: issue.key,
23
+ summary: issue.fields.summary,
24
+ status: issue.fields.status.name,
25
+ assignee: issue.fields.assignee?.displayName ?? null,
26
+ priority: issue.fields.priority?.name ?? null,
27
+ }));
28
+ }
29
+
30
+ /**
31
+ * Obtener las transiciones posibles para un issue
32
+ */
33
+ export async function getIssueTransitions(issueKey) {
34
+ const data = await jiraRequest(`/rest/api/3/issue/${issueKey}/transitions`, {
35
+ method: 'GET',
36
+ });
37
+
38
+ const transitions = data.transitions ?? [];
39
+
40
+ return transitions.map((t) => ({
41
+ id: t.id,
42
+ name: t.name,
43
+ toStatus: t.to.name,
44
+ }));
45
+ }
46
+
47
+ /**
48
+ * Ejecutar una transición (mover a otro estado/columna)
49
+ */
50
+ export async function transitionIssue(issueKey, transitionId) {
51
+ const body = {
52
+ transition: { id: transitionId },
53
+ };
54
+
55
+ const jiraRequest = await jiraRequest(`/rest/api/3/issue/${issueKey}/transitions`, {
56
+ method: 'POST',
57
+ body: JSON.stringify(body),
58
+ });
59
+
60
+ return { ok: true };
61
+ }
package/jiraClient.mjs ADDED
@@ -0,0 +1,72 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import dotenv from 'dotenv';
5
+ import { Buffer } from 'buffer';
6
+ import process from 'process';
7
+
8
+ let envLoaded = false;
9
+
10
+ function loadGlobalEnvOnce() {
11
+ if (envLoaded) return;
12
+ envLoaded = true;
13
+ const home = os.homedir();
14
+ const candidates = [path.join(home, '.jira-mcp.env'), path.join(home, '.jira-mcp', '.env')];
15
+
16
+ for (const p of candidates) {
17
+ try {
18
+ if (fs.existsSync(p)) {
19
+ dotenv.config({ path: p });
20
+ break;
21
+ }
22
+ } catch {
23
+ // ignore
24
+ }
25
+ }
26
+ }
27
+
28
+ loadGlobalEnvOnce();
29
+
30
+ // --- a partir de aquí, uso normal de env:
31
+ const baseUrl = process.env.JIRA_BASE_URL;
32
+ const email = process.env.JIRA_EMAIL;
33
+ const apiToken = process.env.JIRA_API_TOKEN;
34
+
35
+ if (!baseUrl || !email || !apiToken) {
36
+ throw new Error(
37
+ 'Faltan credenciales de Jira. Crea un .env global en tu HOME:\n' +
38
+ '- macOS/Linux: ~/.jira-mcp.env (o ~/.jira-mcp/.env)\n' +
39
+ '- Windows: %USERPROFILE%\\.jira-mcp.env (o %USERPROFILE%\\.jira-mcp\\.env)\n\n' +
40
+ 'Variables requeridas:\n' +
41
+ 'JIRA_BASE_URL=https://<tu-org>.atlassian.net\n' +
42
+ 'JIRA_EMAIL=tu-email@empresa.com\n' +
43
+ 'JIRA_API_TOKEN=<tu-token>\n',
44
+ );
45
+ }
46
+
47
+ const authHeader = 'Basic ' + Buffer.from(`${email}:${apiToken}`).toString('base64');
48
+
49
+ /**
50
+ * Llamada genérica a Jira con manejo de errores
51
+ */
52
+ export async function jiraRequest(path, options = {}) {
53
+ const url = `${baseUrl}${path}`;
54
+
55
+ const response = await fetch(url, {
56
+ ...options,
57
+ headers: {
58
+ Authorization: authHeader,
59
+ Accept: 'application/json',
60
+ 'Content-Type': 'application/json',
61
+ ...(options.headers || {}),
62
+ },
63
+ });
64
+
65
+ const text = await response.text();
66
+
67
+ if (!response.ok) {
68
+ throw new Error(`Jira error ${response.status} ${response.statusText}: ${text.slice(0, 400)}`);
69
+ }
70
+
71
+ return text ? JSON.parse(text) : {};
72
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@itzjoordi/jira-mcp",
3
+ "version": "1.0.0",
4
+ "description": "Servidor MCP para Jira Mercadona usable desde Github Copilot Chat",
5
+ "type": "module",
6
+ "bin": {
7
+ "jira-mcp": "./server.mjs"
8
+ },
9
+ "scripts": {
10
+ "lint": "eslint .",
11
+ "lint:fix": "eslint . --fix",
12
+ "format": "prettier --write ."
13
+ },
14
+ "dependencies": {
15
+ "@modelcontextprotocol/sdk": "^1.22.0",
16
+ "dotenv": "^17.2.3",
17
+ "zod": "^3.25.76"
18
+ },
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "license": "MIT",
23
+ "devDependencies": {
24
+ "@eslint/js": "^9.39.1",
25
+ "eslint": "^9.39.1",
26
+ "globals": "^16.5.0",
27
+ "prettier": "^3.6.2"
28
+ }
29
+ }
package/server.mjs ADDED
@@ -0,0 +1,236 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { z } from 'zod';
5
+
6
+ import { searchIssues, getIssueTransitions, transitionIssue } from './jiraApi.mjs';
7
+
8
+ // 1. Crear servidor MCP
9
+ const server = new McpServer({
10
+ name: 'jira-mcp',
11
+ version: '0.1.0',
12
+ });
13
+
14
+ // 2. Tools
15
+ // Tool: listar issues con filtros simples
16
+ server.registerTool(
17
+ 'jira_list_filtered',
18
+ {
19
+ title: 'Jira: listar issues con filtros simples',
20
+ description:
21
+ 'Lista issues de Jira usando filtros de alto nivel (solo mías, en curso, backlog, etc.) y construye la JQL automáticamente.',
22
+ inputSchema: {
23
+ projectKey: z
24
+ .string()
25
+ .optional()
26
+ .describe("Clave de proyecto, p.ej. 'SIM1'. Si se omite, busca en todos."),
27
+ onlyAssignedToMe: z
28
+ .boolean()
29
+ .optional()
30
+ .describe('Si es true, solo issues asignadas al usuario actual.'),
31
+ excludeAssignedToMe: z
32
+ .boolean()
33
+ .optional()
34
+ .describe('Si es true, excluye las issues asignadas al usuario actual.'),
35
+ statusCategory: z
36
+ .enum(['To Do', 'In Progress', 'Done'])
37
+ .optional()
38
+ .describe('Filtrar por categoría de estado de Jira.'),
39
+ statusIn: z
40
+ .array(z.string())
41
+ .optional()
42
+ .describe('Lista de nombres de estado, p.ej. ["Por Hacer", "Validar"].'),
43
+ backlogOnly: z
44
+ .boolean()
45
+ .optional()
46
+ .describe('Si es true, devuelve issues de backlog (aprox: no Done, normalmente To Do).'),
47
+ maxResults: z
48
+ .number()
49
+ .int()
50
+ .min(1)
51
+ .max(50)
52
+ .optional()
53
+ .describe('Máximo de issues a devolver (por defecto 20).'),
54
+ },
55
+ },
56
+ async ({
57
+ projectKey,
58
+ onlyAssignedToMe,
59
+ excludeAssignedToMe,
60
+ statusCategory,
61
+ statusIn,
62
+ backlogOnly,
63
+ maxResults,
64
+ }) => {
65
+ const clauses = [];
66
+
67
+ if (projectKey) {
68
+ clauses.push(`project = ${projectKey}`);
69
+ }
70
+
71
+ if (onlyAssignedToMe) {
72
+ clauses.push('assignee = currentUser()');
73
+ } else if (excludeAssignedToMe) {
74
+ clauses.push('assignee != currentUser()');
75
+ }
76
+
77
+ if (statusCategory) {
78
+ clauses.push(`statusCategory = "${statusCategory}"`);
79
+ }
80
+
81
+ if (statusIn && statusIn.length > 0) {
82
+ const quoted = statusIn.map((s) => `"${s}"`).join(', ');
83
+ clauses.push(`status in (${quoted})`);
84
+ }
85
+
86
+ if (backlogOnly) {
87
+ // Aproximación: backlog = no Done
88
+ clauses.push('statusCategory != "Done"');
89
+ }
90
+
91
+ let jql = clauses.join(' AND ');
92
+ const order = 'ORDER BY created DESC';
93
+
94
+ if (jql.trim().length === 0) {
95
+ jql = order;
96
+ } else {
97
+ jql = `${jql} ${order}`;
98
+ }
99
+
100
+ const issues = await searchIssues(jql, maxResults ?? 20);
101
+
102
+ const humanText =
103
+ issues.length === 0
104
+ ? `No se han encontrado issues para la JQL: ${jql}`
105
+ : `JQL usada: ${jql}\n\n` +
106
+ issues
107
+ .map(
108
+ (i) =>
109
+ `${i.key} | ${i.status} | ${i.summary}` + (i.assignee ? ` (${i.assignee})` : ''),
110
+ )
111
+ .join('\n');
112
+
113
+ return {
114
+ content: [
115
+ { type: 'text', text: humanText },
116
+ {
117
+ type: 'text',
118
+ text: '\n\n[JSON crudo para el modelo]\n' + JSON.stringify({ jql, issues }, null, 2),
119
+ },
120
+ ],
121
+ structuredContent: { jql, issues },
122
+ };
123
+ },
124
+ );
125
+
126
+ // Tool: obtener transiciones de un issue
127
+ server.registerTool(
128
+ 'jira_get_transitions',
129
+ {
130
+ title: 'Jira: obtener transiciones de un issue',
131
+ description:
132
+ 'Devuelve las transiciones posibles (id, name, toStatus) para un issue de Jira concreto.',
133
+ inputSchema: {
134
+ issueKey: z.string().describe("Clave del issue, p.ej. 'SIM1-320'"),
135
+ },
136
+ // outputSchema opcional:
137
+ // outputSchema: z.array(
138
+ // z.object({
139
+ // id: z.string(),
140
+ // name: z.string(),
141
+ // toStatus: z.string(),
142
+ // })
143
+ // ),
144
+ },
145
+ async ({ issueKey }) => {
146
+ const transitions = await getIssueTransitions(issueKey);
147
+
148
+ return {
149
+ content: [
150
+ {
151
+ type: 'text',
152
+ text: JSON.stringify(transitions, null, 2),
153
+ },
154
+ ],
155
+ structuredContent: transitions,
156
+ };
157
+ },
158
+ );
159
+
160
+ // Tool: hacer transición de un issue
161
+ server.registerTool(
162
+ 'jira_transition_issue',
163
+ {
164
+ title: 'Jira: cambiar estado de un issue',
165
+ description: 'Ejecuta una transición de workflow en un issue de Jira usando su transitionId.',
166
+ inputSchema: {
167
+ issueKey: z.string().describe("Clave del issue, p.ej. 'SIM1-320'"),
168
+ transitionId: z.string().describe('ID de la transición (devuelta por jira_get_transitions)'),
169
+ },
170
+ // outputSchema opcional:
171
+ // outputSchema: z.object({ ok: z.boolean() }),
172
+ },
173
+ async ({ issueKey, transitionId }) => {
174
+ const result = await transitionIssue(issueKey, transitionId);
175
+
176
+ return {
177
+ content: [
178
+ {
179
+ type: 'text',
180
+ text: JSON.stringify(result, null, 2),
181
+ },
182
+ ],
183
+ structuredContent: result,
184
+ };
185
+ },
186
+ );
187
+
188
+ // Tool: mover issue por nombre de transición
189
+ server.registerTool(
190
+ 'jira_move_issue_by_transition_name',
191
+ {
192
+ title: 'Jira: mover issue por nombre de transición',
193
+ description:
194
+ "Busca la transición por nombre (p.ej. 'Construir', 'Validar') y la ejecuta sobre el issue.",
195
+ inputSchema: {
196
+ issueKey: z.string().describe("Clave del issue, p.ej. 'SIM1-320'"),
197
+ transitionName: z.string().describe("Nombre exacto de la transición, p.ej. 'Construir'"),
198
+ },
199
+ },
200
+ async ({ issueKey, transitionName }) => {
201
+ const transitions = await getIssueTransitions(issueKey);
202
+ const match = transitions.find((t) => t.name.toLowerCase() === transitionName.toLowerCase());
203
+
204
+ if (!match) {
205
+ const available = transitions.map((t) => t.name).join(', ');
206
+ const msg =
207
+ `No se ha encontrado ninguna transición llamada "${transitionName}" ` +
208
+ `para el issue ${issueKey}. Transiciones disponibles: ${available}`;
209
+
210
+ return {
211
+ content: [{ type: 'text', text: msg }],
212
+ structuredContent: { ok: false, error: msg, transitions },
213
+ };
214
+ }
215
+
216
+ const result = await transitionIssue(issueKey, match.id);
217
+
218
+ return {
219
+ content: [
220
+ {
221
+ type: 'text',
222
+ text: `Transición "${match.name}" aplicada a ${issueKey}. Nuevo estado: ${match.toStatus}`,
223
+ },
224
+ {
225
+ type: 'text',
226
+ text: '\n[Detalle JSON]\n' + JSON.stringify({ result, usedTransition: match }, null, 2),
227
+ },
228
+ ],
229
+ structuredContent: { ok: true, usedTransition: match },
230
+ };
231
+ },
232
+ );
233
+
234
+ // 3. Conectar por STDIO
235
+ const transport = new StdioServerTransport();
236
+ await server.connect(transport);