@rui.branco/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.
Files changed (4) hide show
  1. package/README.md +183 -0
  2. package/index.js +807 -0
  3. package/package.json +34 -0
  4. package/setup.js +87 -0
package/README.md ADDED
@@ -0,0 +1,183 @@
1
+ # Jira MCP Server
2
+
3
+ A [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that brings Jira ticket context directly into Claude Code. Fetch complete ticket information including descriptions, comments, attachments, and linked Figma designs without leaving your development environment.
4
+
5
+ ## Overview
6
+
7
+ When working on development tasks, context switching between Jira and your code editor breaks flow and wastes time. This MCP server solves that by:
8
+
9
+ - **Fetching complete ticket context** - Get descriptions, comments, status, and metadata instantly
10
+ - **Downloading attachments** - Image attachments are downloaded and displayed inline
11
+ - **Auto-fetching Figma designs** - Linked Figma URLs are automatically detected and exported as images
12
+ - **Enabling natural queries** - Search tickets with JQL directly from Claude Code
13
+
14
+ ## Features
15
+
16
+ | Feature | Description |
17
+ |---------|-------------|
18
+ | Full Ticket Details | Summary, description, status, priority, assignee, reporter, sprint, parent |
19
+ | Comments | All comments with author and timestamp |
20
+ | Attachments | Auto-download image attachments (PNG, JPG, GIF, WebP) |
21
+ | Linked Issues | View related tickets and their relationships |
22
+ | Figma Integration | Auto-detect and export Figma designs linked in tickets |
23
+ | JQL Search | Search across your Jira instance with powerful queries |
24
+
25
+ ## Installation
26
+
27
+ ### Prerequisites
28
+
29
+ - Node.js 18+
30
+ - [Claude Code](https://claude.ai/code) CLI
31
+ - Jira Cloud account with API access
32
+
33
+ ### Quick Setup
34
+
35
+ ```bash
36
+ # Clone to ~/.config (recommended)
37
+ cd ~/.config
38
+ git clone https://github.com/rui-branco/jira-mcp.git
39
+ cd jira-mcp
40
+
41
+ # Install dependencies
42
+ npm install
43
+
44
+ # Run interactive setup
45
+ node setup.js
46
+ ```
47
+
48
+ The setup will prompt for:
49
+ 1. **Jira email** - Your Atlassian account email
50
+ 2. **API token** - Generate at [Atlassian API Tokens](https://id.atlassian.com/manage-profile/security/api-tokens)
51
+ 3. **Base URL** - Your Jira instance (e.g., `https://company.atlassian.net`)
52
+
53
+ ### Alternative: Command-Line Setup
54
+
55
+ ```bash
56
+ node setup.js "your@email.com" "YOUR_API_TOKEN" "https://company.atlassian.net"
57
+ ```
58
+
59
+ ### Claude Code Configuration
60
+
61
+ Add to your `~/.claude.json`:
62
+
63
+ ```json
64
+ {
65
+ "mcpServers": {
66
+ "jira": {
67
+ "type": "stdio",
68
+ "command": "node",
69
+ "args": ["~/.config/jira-mcp/index.js"]
70
+ }
71
+ }
72
+ }
73
+ ```
74
+
75
+ > **Note:** If you cloned to a different location, update the path accordingly.
76
+
77
+ Restart Claude Code to load the MCP server.
78
+
79
+ ## Usage
80
+
81
+ ### Fetch a Ticket
82
+
83
+ ```
84
+ > Get ticket PROJ-123
85
+
86
+ # Returns full ticket with description, comments, attachments, and Figma designs
87
+ ```
88
+
89
+ ### Search Tickets
90
+
91
+ ```
92
+ > Search for my open tickets
93
+
94
+ # Uses JQL: assignee = currentUser() AND status != Done
95
+ ```
96
+
97
+ ### Example Output
98
+
99
+ ```
100
+ # PROJ-123: Implement user authentication
101
+
102
+ Status: In Progress | Type: Story | Priority: High
103
+ Assignee: John Doe | Reporter: Jane Smith
104
+
105
+ ## Description
106
+ Implement OAuth2 authentication flow...
107
+
108
+ ## Comments (2)
109
+ ### Jane Smith - Jan 15, 2025
110
+ Please ensure we support Google SSO...
111
+
112
+ ## Attachments (1)
113
+ - mockup.png (image/png, 245KB)
114
+ [Image displayed inline]
115
+
116
+ ## Figma Designs (1)
117
+ ### Auth Flow Design - Login Screen
118
+ - Exported 3 image(s):
119
+ - Login Form: ~/.config/figma-mcp/exports/...
120
+ - Error States: ~/.config/figma-mcp/exports/...
121
+ - Success State: ~/.config/figma-mcp/exports/...
122
+ ```
123
+
124
+ ## Figma Integration
125
+
126
+ This MCP automatically detects Figma URLs in ticket descriptions and comments. When [figma-mcp](https://github.com/rui-branco/figma-mcp) is configured:
127
+
128
+ - Figma links are automatically fetched
129
+ - Large frames are split into sections for better readability
130
+ - Images are exported at 2x scale for clarity
131
+ - All images are displayed inline in Claude Code
132
+
133
+ To enable Figma integration:
134
+ 1. Install and configure [figma-mcp](https://github.com/rui-branco/figma-mcp)
135
+ 2. Restart Claude Code
136
+ 3. Figma links will be auto-fetched when you get a ticket
137
+
138
+ ## API Reference
139
+
140
+ ### Tools
141
+
142
+ | Tool | Description | Parameters |
143
+ |------|-------------|------------|
144
+ | `jira_get_ticket` | Fetch a ticket by key | `issueKey` (required), `downloadImages`, `fetchFigma` |
145
+ | `jira_search` | Search with JQL | `jql` (required), `maxResults` |
146
+
147
+ ### Configuration
148
+
149
+ Config stored at `~/.config/jira-mcp/config.json`:
150
+
151
+ ```json
152
+ {
153
+ "email": "your@email.com",
154
+ "token": "YOUR_API_TOKEN",
155
+ "baseUrl": "https://company.atlassian.net"
156
+ }
157
+ ```
158
+
159
+ ## Error Handling
160
+
161
+ The server provides clear error messages:
162
+
163
+ | Error | Meaning |
164
+ |-------|---------|
165
+ | `Figma API rate limit exceeded` | Too many Figma requests, wait a few minutes |
166
+ | `Figma access denied` | Check Figma token or file permissions |
167
+ | `Figma not configured` | Install and configure figma-mcp |
168
+
169
+ ## Security
170
+
171
+ - API tokens are stored locally in `~/.config/jira-mcp/config.json`
172
+ - Config files are excluded from git via `.gitignore`
173
+ - Tokens are never logged or transmitted except to Jira/Figma APIs
174
+ - Attachments are downloaded to `~/.config/jira-mcp/attachments/`
175
+
176
+ ## License
177
+
178
+ MIT
179
+
180
+ ## Related
181
+
182
+ - [figma-mcp](https://github.com/rui-branco/figma-mcp) - Figma MCP server for Claude Code
183
+ - [Model Context Protocol](https://modelcontextprotocol.io/) - MCP specification
package/index.js ADDED
@@ -0,0 +1,807 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { Server } = require("@modelcontextprotocol/sdk/server/index.js");
4
+ const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");
5
+ const {
6
+ CallToolRequestSchema,
7
+ ListToolsRequestSchema,
8
+ } = require("@modelcontextprotocol/sdk/types.js");
9
+ const fs = require("fs");
10
+ const path = require("path");
11
+ const fetch = require("node-fetch");
12
+
13
+ // Load Jira config
14
+ const jiraConfigPath = path.join(process.env.HOME, ".config/jira-mcp/config.json");
15
+ const jiraConfig = JSON.parse(fs.readFileSync(jiraConfigPath, "utf8"));
16
+ const auth = Buffer.from(`${jiraConfig.email}:${jiraConfig.token}`).toString("base64");
17
+
18
+ // Load Figma config (optional)
19
+ let figmaConfig = null;
20
+ const figmaConfigPath = path.join(process.env.HOME, ".config/figma-mcp/config.json");
21
+ try {
22
+ if (fs.existsSync(figmaConfigPath)) {
23
+ figmaConfig = JSON.parse(fs.readFileSync(figmaConfigPath, "utf8"));
24
+ }
25
+ } catch (e) {
26
+ // Figma not configured, that's ok
27
+ }
28
+
29
+ // Directories
30
+ const attachmentDir = path.join(process.env.HOME, ".config/jira-mcp/attachments");
31
+ const figmaExportsDir = path.join(process.env.HOME, ".config/figma-mcp/exports");
32
+
33
+ if (!fs.existsSync(attachmentDir)) {
34
+ fs.mkdirSync(attachmentDir, { recursive: true });
35
+ }
36
+
37
+ // ============ JIRA FUNCTIONS ============
38
+
39
+ async function fetchJira(endpoint) {
40
+ const response = await fetch(`${jiraConfig.baseUrl}/rest/api/3${endpoint}`, {
41
+ headers: {
42
+ Authorization: `Basic ${auth}`,
43
+ Accept: "application/json",
44
+ },
45
+ });
46
+ if (!response.ok) {
47
+ throw new Error(`Jira API error: ${response.status} ${response.statusText}`);
48
+ }
49
+ return response.json();
50
+ }
51
+
52
+ async function downloadAttachment(url, filename, issueKey) {
53
+ const issueDir = path.join(attachmentDir, issueKey);
54
+ if (!fs.existsSync(issueDir)) {
55
+ fs.mkdirSync(issueDir, { recursive: true });
56
+ }
57
+
58
+ const localPath = path.join(issueDir, filename);
59
+
60
+ if (fs.existsSync(localPath)) {
61
+ return localPath;
62
+ }
63
+
64
+ const response = await fetch(url, {
65
+ headers: { Authorization: `Basic ${auth}` },
66
+ });
67
+
68
+ if (!response.ok) {
69
+ throw new Error(`Failed to download ${filename}: ${response.status}`);
70
+ }
71
+
72
+ const buffer = await response.buffer();
73
+ fs.writeFileSync(localPath, buffer);
74
+
75
+ return localPath;
76
+ }
77
+
78
+ function extractText(content, urls = []) {
79
+ if (!content) return { text: "", urls };
80
+ if (typeof content === "string") return { text: content, urls };
81
+
82
+ let text = "";
83
+ if (content.content) {
84
+ for (const node of content.content) {
85
+ if (node.type === "text") {
86
+ text += node.text || "";
87
+ // Check for link marks
88
+ if (node.marks) {
89
+ for (const mark of node.marks) {
90
+ if (mark.type === "link" && mark.attrs?.href) {
91
+ urls.push(mark.attrs.href);
92
+ }
93
+ }
94
+ }
95
+ } else if (node.type === "paragraph") {
96
+ const result = extractText(node, urls);
97
+ text += result.text + "\n";
98
+ urls = result.urls;
99
+ } else if (node.type === "hardBreak") {
100
+ text += "\n";
101
+ } else if (node.type === "mention") {
102
+ text += `@${node.attrs?.text || "user"}`;
103
+ } else if (node.type === "mediaGroup" || node.type === "mediaSingle") {
104
+ text += "[image attachment]\n";
105
+ } else if (node.type === "inlineCard" || node.type === "blockCard" || node.type === "embedCard") {
106
+ // Smart links / embeds - extract URL
107
+ const url = node.attrs?.url;
108
+ if (url) {
109
+ text += url + "\n";
110
+ urls.push(url);
111
+ }
112
+ } else if (node.content) {
113
+ const result = extractText(node, urls);
114
+ text += result.text;
115
+ urls = result.urls;
116
+ }
117
+ }
118
+ }
119
+ return { text, urls };
120
+ }
121
+
122
+ // Wrapper for backward compatibility
123
+ function extractTextSimple(content) {
124
+ const result = extractText(content, []);
125
+ return result.text;
126
+ }
127
+
128
+ // ============ JIRA URL DETECTION ============
129
+
130
+ function findJiraTicketKeys(text, currentKey = null) {
131
+ if (!text) return [];
132
+
133
+ // Match Jira URLs like https://company.atlassian.net/browse/PROJ-123
134
+ const urlRegex = /https?:\/\/[^\s]+\/browse\/([A-Z][A-Z0-9]+-\d+)/g;
135
+ // Match ticket keys directly like PROJ-123
136
+ const keyRegex = /\b([A-Z][A-Z0-9]+-\d+)\b/g;
137
+
138
+ const keys = new Set();
139
+ let match;
140
+
141
+ while ((match = urlRegex.exec(text)) !== null) {
142
+ if (match[1] !== currentKey) {
143
+ keys.add(match[1]);
144
+ }
145
+ }
146
+
147
+ while ((match = keyRegex.exec(text)) !== null) {
148
+ if (match[1] !== currentKey) {
149
+ keys.add(match[1]);
150
+ }
151
+ }
152
+
153
+ return [...keys];
154
+ }
155
+
156
+ // ============ FIGMA FUNCTIONS ============
157
+
158
+ function findFigmaUrls(text) {
159
+ if (!text) return [];
160
+ // Match Figma URLs
161
+ const regex = /https:\/\/(?:www\.)?figma\.com\/(?:file|design|proto)\/([a-zA-Z0-9]+)\/[^\s)>\]"']*/g;
162
+ const matches = [];
163
+ let match;
164
+ while ((match = regex.exec(text)) !== null) {
165
+ matches.push(match[0]);
166
+ }
167
+ return [...new Set(matches)]; // dedupe
168
+ }
169
+
170
+ function parseFigmaUrl(url) {
171
+ const urlObj = new URL(url);
172
+ const pathParts = urlObj.pathname.split("/");
173
+
174
+ let fileKey = null;
175
+ for (let i = 0; i < pathParts.length; i++) {
176
+ if (pathParts[i] === "file" || pathParts[i] === "design" || pathParts[i] === "proto") {
177
+ fileKey = pathParts[i + 1];
178
+ break;
179
+ }
180
+ }
181
+
182
+ if (!fileKey) return null;
183
+
184
+ // Get node ID and convert from hyphen to colon format
185
+ const rawNodeId = urlObj.searchParams.get("node-id");
186
+ const nodeId = rawNodeId ? rawNodeId.replace(/-/g, ":") : null;
187
+
188
+ return { fileKey, nodeId };
189
+ }
190
+
191
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
192
+
193
+ async function figmaFetchWithRetry(url, options = {}, { maxRetries = 3, maxWaitSec = 30 } = {}) {
194
+ let attempts = 0;
195
+
196
+ while (true) {
197
+ const response = await fetch(url, {
198
+ ...options,
199
+ headers: { ...options.headers, "X-Figma-Token": figmaConfig.token },
200
+ });
201
+
202
+ if (response.ok) {
203
+ return response;
204
+ }
205
+
206
+ if (response.status === 429) {
207
+ const retryAfterSec = Number(response.headers.get("retry-after")) || 60;
208
+
209
+ // Don't retry if wait is too long (monthly limit) or too many attempts
210
+ if (retryAfterSec > maxWaitSec || attempts++ >= maxRetries) {
211
+ const waitTime = retryAfterSec > 3600
212
+ ? `${Math.round(retryAfterSec / 3600)} hours (monthly limit reached)`
213
+ : `${retryAfterSec} seconds`;
214
+ return { rateLimited: true, retryAfter: waitTime };
215
+ }
216
+
217
+ await sleep(retryAfterSec * 1000);
218
+ continue;
219
+ }
220
+
221
+ return response;
222
+ }
223
+ }
224
+
225
+ async function fetchFigmaDesign(url) {
226
+ if (!figmaConfig) {
227
+ return { error: "Figma not configured. Run: node ~/.config/figma-mcp/setup.js" };
228
+ }
229
+
230
+ const parsed = parseFigmaUrl(url);
231
+ if (!parsed) {
232
+ return { error: "Invalid Figma URL" };
233
+ }
234
+
235
+ const { fileKey, nodeId } = parsed;
236
+
237
+ try {
238
+ // Get file info
239
+ const fileRes = await figmaFetchWithRetry(`https://api.figma.com/v1/files/${fileKey}?depth=1`);
240
+
241
+ if (fileRes.rateLimited) {
242
+ return { error: `Figma API rate limit exceeded. Try again in ${fileRes.retryAfter} seconds.` };
243
+ }
244
+ if (!fileRes.ok) {
245
+ if (fileRes.status === 403) {
246
+ return { error: "Figma access denied. Check your token or file permissions." };
247
+ } else if (fileRes.status === 404) {
248
+ return { error: "Figma file not found. Check the URL." };
249
+ }
250
+ return { error: `Figma API error: ${fileRes.status}` };
251
+ }
252
+ const fileData = await fileRes.json();
253
+
254
+ let result = {
255
+ name: fileData.name,
256
+ lastModified: fileData.lastModified,
257
+ url: url,
258
+ nodeId: nodeId,
259
+ nodeName: null,
260
+ images: [], // Changed to array for multiple images
261
+ };
262
+
263
+ if (!fs.existsSync(figmaExportsDir)) {
264
+ fs.mkdirSync(figmaExportsDir, { recursive: true });
265
+ }
266
+
267
+ // If specific node, get its info and export images
268
+ if (nodeId) {
269
+ // Get node info with depth=2 to see children
270
+ const nodeRes = await figmaFetchWithRetry(
271
+ `https://api.figma.com/v1/files/${fileKey}/nodes?ids=${encodeURIComponent(nodeId)}&depth=2`
272
+ );
273
+
274
+ if (nodeRes.ok) {
275
+ const nodeData = await nodeRes.json();
276
+ const node = nodeData.nodes && nodeData.nodes[nodeId];
277
+
278
+ if (node && node.document) {
279
+ const doc = node.document;
280
+ result.nodeName = doc.name;
281
+
282
+ // Check if it's a large frame with children
283
+ const bb = doc.absoluteBoundingBox;
284
+ const isLarge = bb && (bb.width > 1500 || bb.height > 2000);
285
+ const exportableChildren = [];
286
+
287
+ if (doc.children) {
288
+ for (const child of doc.children) {
289
+ const isExportable = ["FRAME", "COMPONENT", "GROUP", "SECTION"].includes(child.type);
290
+ const cbb = child.absoluteBoundingBox;
291
+ const hasSize = cbb && cbb.width >= 100 && cbb.height >= 100;
292
+ if (isExportable && hasSize) {
293
+ exportableChildren.push({ id: child.id, name: child.name });
294
+ }
295
+ }
296
+ }
297
+
298
+ // Export children if large frame, otherwise export whole frame
299
+ if (isLarge && exportableChildren.length > 0) {
300
+ const childIds = exportableChildren.slice(0, 8).map(c => c.id);
301
+ const idsParam = childIds.join(",");
302
+
303
+ const imgRes = await figmaFetchWithRetry(
304
+ `https://api.figma.com/v1/images/${fileKey}?ids=${encodeURIComponent(idsParam)}&format=png&scale=2`
305
+ );
306
+
307
+ if (imgRes.ok) {
308
+ const imgData = await imgRes.json();
309
+ for (const childId of childIds) {
310
+ const imageUrl = imgData.images && imgData.images[childId];
311
+ if (imageUrl) {
312
+ try {
313
+ const downloadRes = await fetch(imageUrl);
314
+ if (downloadRes.ok) {
315
+ const buffer = await downloadRes.buffer();
316
+ const childInfo = exportableChildren.find(c => c.id === childId);
317
+ const sanitizedId = childId.replace(/[^a-zA-Z0-9-]/g, "_");
318
+ const filename = `${fileKey}_${sanitizedId}.png`;
319
+ const imagePath = path.join(figmaExportsDir, filename);
320
+ fs.writeFileSync(imagePath, buffer);
321
+ result.images.push({
322
+ buffer,
323
+ path: imagePath,
324
+ name: childInfo?.name || childId
325
+ });
326
+ }
327
+ } catch (e) { /* skip */ }
328
+ }
329
+ }
330
+ }
331
+ } else {
332
+ // Export whole frame
333
+ const imgRes = await figmaFetchWithRetry(
334
+ `https://api.figma.com/v1/images/${fileKey}?ids=${encodeURIComponent(nodeId)}&format=png&scale=2`
335
+ );
336
+
337
+ if (imgRes.ok) {
338
+ const imgData = await imgRes.json();
339
+ const imageUrl = imgData.images && imgData.images[nodeId];
340
+ if (imageUrl) {
341
+ const downloadRes = await fetch(imageUrl);
342
+ if (downloadRes.ok) {
343
+ const buffer = await downloadRes.buffer();
344
+ const sanitizedId = nodeId.replace(/[^a-zA-Z0-9-]/g, "_");
345
+ const filename = `${fileKey}_${sanitizedId}.png`;
346
+ const imagePath = path.join(figmaExportsDir, filename);
347
+ fs.writeFileSync(imagePath, buffer);
348
+ result.images.push({ buffer, path: imagePath, name: doc.name });
349
+ }
350
+ }
351
+ }
352
+ }
353
+ }
354
+ }
355
+ }
356
+
357
+ return result;
358
+ } catch (e) {
359
+ return { error: `Figma fetch failed: ${e.message}` };
360
+ }
361
+ }
362
+
363
+ // ============ MAIN TICKET FUNCTION ============
364
+
365
+ async function getTicket(issueKey, downloadImages = true, fetchFigma = true) {
366
+ const issue = await fetchJira(`/issue/${issueKey}?expand=renderedFields`);
367
+ const fields = issue.fields;
368
+
369
+ let output = `# ${issueKey}: ${fields.summary}\n\n`;
370
+ output += `**Status:** ${fields.status?.name || "Unknown"}\n`;
371
+ output += `**Type:** ${fields.issuetype?.name || "Unknown"}\n`;
372
+ output += `**Priority:** ${fields.priority?.name || "None"}\n`;
373
+ output += `**Assignee:** ${fields.assignee?.displayName || "Unassigned"}\n`;
374
+ output += `**Reporter:** ${fields.reporter?.displayName || "Unknown"}\n`;
375
+
376
+ if (fields.sprint) {
377
+ output += `**Sprint:** ${fields.sprint.name}\n`;
378
+ }
379
+
380
+ if (fields.parent) {
381
+ output += `**Parent:** ${fields.parent.key} - ${fields.parent.fields?.summary || ""}\n`;
382
+ }
383
+
384
+ // Subtasks
385
+ if (fields.subtasks?.length > 0) {
386
+ output += `**Subtasks:** ${fields.subtasks.length}\n`;
387
+ }
388
+
389
+ // Extract description text and embedded URLs
390
+ const descResult = extractText(fields.description, []);
391
+ output += `\n## Description\n\n`;
392
+ output += descResult.text || "_No description_";
393
+ output += "\n";
394
+
395
+ // Collect all URLs found in the ticket
396
+ let allUrls = [...descResult.urls];
397
+ let allText = descResult.text;
398
+
399
+ // Fetch FULL parent ticket details
400
+ if (fields.parent) {
401
+ output += `\n## Parent Ticket: ${fields.parent.key}\n\n`;
402
+ try {
403
+ const parentIssue = await fetchJira(`/issue/${fields.parent.key}?expand=renderedFields`);
404
+ const pf = parentIssue.fields;
405
+
406
+ output += `**${pf.summary}**\n`;
407
+ output += `Status: ${pf.status?.name || "Unknown"} | `;
408
+ output += `Type: ${pf.issuetype?.name || "Unknown"} | `;
409
+ output += `Priority: ${pf.priority?.name || "None"}\n`;
410
+ output += `Assignee: ${pf.assignee?.displayName || "Unassigned"}\n\n`;
411
+
412
+ // Full description
413
+ const parentDesc = extractText(pf.description, []);
414
+ if (parentDesc.text && parentDesc.text.trim()) {
415
+ output += `### Description\n${parentDesc.text}\n`;
416
+ allText += " " + parentDesc.text;
417
+ allUrls = allUrls.concat(parentDesc.urls);
418
+ }
419
+
420
+ // Parent comments
421
+ if (pf.comment?.comments?.length > 0) {
422
+ output += `\n### Comments (${pf.comment.comments.length})\n`;
423
+ for (const comment of pf.comment.comments) {
424
+ const author = comment.author?.displayName || "Unknown";
425
+ const created = new Date(comment.created).toLocaleString();
426
+ const commentText = extractText(comment.body, []);
427
+ output += `**${author}** - ${created}\n`;
428
+ output += `${commentText.text}\n\n`;
429
+ allText += " " + commentText.text;
430
+ }
431
+ }
432
+
433
+ output += "\n---\n\n";
434
+ } catch (e) {
435
+ output += `_Could not fetch parent details: ${e.message}_\n\n`;
436
+ }
437
+ }
438
+
439
+ // Get comments
440
+ if (fields.comment?.comments?.length > 0) {
441
+ output += `\n## Comments (${fields.comment.comments.length})\n\n`;
442
+ for (const comment of fields.comment.comments) {
443
+ const author = comment.author?.displayName || "Unknown";
444
+ const created = new Date(comment.created).toLocaleString();
445
+ const commentResult = extractText(comment.body, []);
446
+ output += `### ${author} - ${created}\n`;
447
+ output += commentResult.text + "\n\n";
448
+ allText += " " + commentResult.text;
449
+ allUrls = allUrls.concat(commentResult.urls);
450
+ }
451
+ }
452
+
453
+ // Get attachments
454
+ const downloadedImages = [];
455
+ if (fields.attachment?.length > 0) {
456
+ output += `\n## Attachments (${fields.attachment.length})\n\n`;
457
+ for (const att of fields.attachment) {
458
+ const isImage = att.mimeType?.startsWith("image/");
459
+ output += `- **${att.filename}** (${att.mimeType}, ${Math.round(att.size / 1024)}KB)\n`;
460
+
461
+ if (downloadImages && isImage) {
462
+ try {
463
+ const localPath = await downloadAttachment(att.content, att.filename, issueKey);
464
+ output += ` Local: ${localPath}\n`;
465
+ downloadedImages.push(localPath);
466
+ } catch (e) {
467
+ output += ` Download failed: ${e.message}\n`;
468
+ }
469
+ } else {
470
+ output += ` URL: ${att.content}\n`;
471
+ }
472
+ }
473
+ }
474
+
475
+ // Subtasks - fetch full details
476
+ if (fields.subtasks?.length > 0) {
477
+ output += `\n## Subtasks (${fields.subtasks.length})\n\n`;
478
+
479
+ for (const subtask of fields.subtasks) {
480
+ output += `### ${subtask.key}: ${subtask.fields?.summary || ""}\n`;
481
+ output += `Status: ${subtask.fields?.status?.name || "Unknown"} | `;
482
+ output += `Type: ${subtask.fields?.issuetype?.name || "Subtask"}\n`;
483
+
484
+ try {
485
+ const subtaskDetails = await fetchJira(`/issue/${subtask.key}`);
486
+ const sf = subtaskDetails.fields;
487
+
488
+ if (sf.assignee) {
489
+ output += `Assignee: ${sf.assignee.displayName}\n`;
490
+ }
491
+
492
+ const subtaskDesc = extractText(sf.description, []);
493
+ if (subtaskDesc.text && subtaskDesc.text.trim()) {
494
+ const desc = subtaskDesc.text.length > 300
495
+ ? subtaskDesc.text.substring(0, 300) + "..."
496
+ : subtaskDesc.text;
497
+ output += `\n${desc}\n`;
498
+ }
499
+ output += "\n";
500
+ } catch (e) {
501
+ output += "\n";
502
+ }
503
+ }
504
+ }
505
+
506
+ // Linked issues - fetch full details
507
+ if (fields.issuelinks?.length > 0) {
508
+ output += `\n## Linked Issues (${fields.issuelinks.length})\n\n`;
509
+
510
+ // Collect linked issue keys
511
+ const linkedIssues = [];
512
+ for (const link of fields.issuelinks) {
513
+ if (link.outwardIssue) {
514
+ linkedIssues.push({
515
+ key: link.outwardIssue.key,
516
+ relation: link.type.outward,
517
+ summary: link.outwardIssue.fields?.summary || ""
518
+ });
519
+ }
520
+ if (link.inwardIssue) {
521
+ linkedIssues.push({
522
+ key: link.inwardIssue.key,
523
+ relation: link.type.inward,
524
+ summary: link.inwardIssue.fields?.summary || ""
525
+ });
526
+ }
527
+ }
528
+
529
+ // Fetch full details for each linked issue
530
+ const maxLinkedToFetch = 10;
531
+ for (let i = 0; i < Math.min(linkedIssues.length, maxLinkedToFetch); i++) {
532
+ const linked = linkedIssues[i];
533
+ output += `### ${linked.relation}: ${linked.key}\n`;
534
+ output += `**${linked.summary}**\n\n`;
535
+
536
+ try {
537
+ const linkedIssue = await fetchJira(`/issue/${linked.key}?expand=renderedFields`);
538
+ const lf = linkedIssue.fields;
539
+
540
+ output += `Status: ${lf.status?.name || "Unknown"} | `;
541
+ output += `Type: ${lf.issuetype?.name || "Unknown"} | `;
542
+ output += `Priority: ${lf.priority?.name || "None"}\n`;
543
+ output += `Assignee: ${lf.assignee?.displayName || "Unassigned"}\n\n`;
544
+
545
+ // Get FULL description (no truncation)
546
+ const linkedDesc = extractText(lf.description, []);
547
+ if (linkedDesc.text && linkedDesc.text.trim()) {
548
+ output += `#### Description\n${linkedDesc.text}\n`;
549
+ }
550
+
551
+ // Get comments from linked ticket
552
+ if (lf.comment?.comments?.length > 0) {
553
+ output += `\n#### Comments (${lf.comment.comments.length})\n`;
554
+ for (const comment of lf.comment.comments) {
555
+ const author = comment.author?.displayName || "Unknown";
556
+ const created = new Date(comment.created).toLocaleString();
557
+ const commentText = extractText(comment.body, []);
558
+ output += `**${author}** - ${created}\n`;
559
+ output += `${commentText.text}\n\n`;
560
+ }
561
+ }
562
+
563
+ output += "\n---\n\n";
564
+ } catch (e) {
565
+ output += `_Could not fetch details: ${e.message}_\n\n`;
566
+ }
567
+ }
568
+
569
+ if (linkedIssues.length > maxLinkedToFetch) {
570
+ output += `\n_...and ${linkedIssues.length - maxLinkedToFetch} more linked issues_\n`;
571
+ }
572
+ }
573
+
574
+ // Find and fetch referenced Jira tickets from text (URLs and ticket keys)
575
+ const referencedKeys = findJiraTicketKeys(allText, issueKey);
576
+
577
+ // Exclude already fetched tickets (linked issues, subtasks, parent)
578
+ const alreadyFetched = new Set();
579
+ alreadyFetched.add(issueKey);
580
+ if (fields.parent) alreadyFetched.add(fields.parent.key);
581
+ if (fields.subtasks) fields.subtasks.forEach(s => alreadyFetched.add(s.key));
582
+ if (fields.issuelinks) {
583
+ fields.issuelinks.forEach(link => {
584
+ if (link.outwardIssue) alreadyFetched.add(link.outwardIssue.key);
585
+ if (link.inwardIssue) alreadyFetched.add(link.inwardIssue.key);
586
+ });
587
+ }
588
+
589
+ const ticketsToFetch = referencedKeys.filter(key => !alreadyFetched.has(key));
590
+
591
+ if (ticketsToFetch.length > 0) {
592
+ output += `\n## Referenced Tickets (${ticketsToFetch.length})\n\n`;
593
+ output += `_Auto-detected from description/comments_\n\n`;
594
+
595
+ const maxReferencedToFetch = 10;
596
+ for (let i = 0; i < Math.min(ticketsToFetch.length, maxReferencedToFetch); i++) {
597
+ const refKey = ticketsToFetch[i];
598
+
599
+ try {
600
+ const refIssue = await fetchJira(`/issue/${refKey}?expand=renderedFields`);
601
+ const rf = refIssue.fields;
602
+
603
+ output += `### ${refKey}: ${rf.summary || ""}\n`;
604
+ output += `Status: ${rf.status?.name || "Unknown"} | `;
605
+ output += `Type: ${rf.issuetype?.name || "Unknown"} | `;
606
+ output += `Priority: ${rf.priority?.name || "None"}\n`;
607
+ output += `Assignee: ${rf.assignee?.displayName || "Unassigned"}\n\n`;
608
+
609
+ // Get FULL description (no truncation)
610
+ const refDesc = extractText(rf.description, []);
611
+ if (refDesc.text && refDesc.text.trim()) {
612
+ output += `#### Description\n${refDesc.text}\n`;
613
+ }
614
+
615
+ // Get comments from referenced ticket
616
+ if (rf.comment?.comments?.length > 0) {
617
+ output += `\n#### Comments (${rf.comment.comments.length})\n`;
618
+ for (const comment of rf.comment.comments) {
619
+ const author = comment.author?.displayName || "Unknown";
620
+ const created = new Date(comment.created).toLocaleString();
621
+ const commentText = extractText(comment.body, []);
622
+ output += `**${author}** - ${created}\n`;
623
+ output += `${commentText.text}\n\n`;
624
+ }
625
+ }
626
+
627
+ // Check for Figma links in referenced ticket
628
+ const refFigmaUrls = findFigmaUrls(refDesc.text);
629
+ if (refFigmaUrls.length > 0) {
630
+ output += `**Figma:** ${refFigmaUrls.join(", ")}\n`;
631
+ allText += " " + refDesc.text;
632
+ }
633
+
634
+ output += "\n---\n\n";
635
+ } catch (e) {
636
+ output += `### ${refKey}\n_Could not fetch: ${e.message}_\n\n`;
637
+ }
638
+ }
639
+
640
+ if (ticketsToFetch.length > maxReferencedToFetch) {
641
+ output += `_...and ${ticketsToFetch.length - maxReferencedToFetch} more referenced tickets_\n`;
642
+ }
643
+ }
644
+
645
+ // Find and fetch Figma designs
646
+ const figmaDesigns = [];
647
+ if (fetchFigma && figmaConfig) {
648
+ // Combine URLs from regex search AND embedded links
649
+ const textUrls = findFigmaUrls(allText);
650
+ const embeddedFigmaUrls = allUrls.filter(u => u && u.includes("figma.com"));
651
+ const allFigmaUrls = [...new Set([...textUrls, ...embeddedFigmaUrls])]; // dedupe
652
+
653
+ if (allFigmaUrls.length > 0) {
654
+ output += `\n## Figma Designs (${allFigmaUrls.length})\n\n`;
655
+
656
+ for (const url of allFigmaUrls) {
657
+ const design = await fetchFigmaDesign(url);
658
+ if (design && design.error) {
659
+ output += `- ${url}\n`;
660
+ output += ` **Error:** ${design.error}\n\n`;
661
+ } else if (design && design.name) {
662
+ output += `### ${design.name}${design.nodeName ? ` - ${design.nodeName}` : ""}\n`;
663
+ output += `- URL: ${url}\n`;
664
+ output += `- Last Modified: ${design.lastModified}\n`;
665
+ if (design.images && design.images.length > 0) {
666
+ output += `- Exported ${design.images.length} image(s):\n`;
667
+ for (const img of design.images) {
668
+ output += ` - ${img.name}: ${img.path}\n`;
669
+ }
670
+ figmaDesigns.push(design);
671
+ }
672
+ output += "\n";
673
+ } else {
674
+ output += `- ${url} (could not fetch)\n\n`;
675
+ }
676
+ }
677
+ }
678
+ }
679
+
680
+ return { text: output, jiraImages: downloadedImages, figmaDesigns };
681
+ }
682
+
683
+ async function searchTickets(jql, maxResults = 10) {
684
+ const data = await fetchJira(`/search?jql=${encodeURIComponent(jql)}&maxResults=${maxResults}`);
685
+
686
+ let output = `# Search Results (${data.total} total, showing ${data.issues.length})\n\n`;
687
+
688
+ for (const issue of data.issues) {
689
+ const f = issue.fields;
690
+ output += `- **${issue.key}**: ${f.summary}\n`;
691
+ output += ` Status: ${f.status?.name} | Assignee: ${f.assignee?.displayName || "Unassigned"}\n\n`;
692
+ }
693
+
694
+ return output;
695
+ }
696
+
697
+ // ============ MCP SERVER ============
698
+
699
+ const server = new Server(
700
+ { name: "jira-mcp", version: "1.0.0" },
701
+ { capabilities: { tools: {} } }
702
+ );
703
+
704
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
705
+ return {
706
+ tools: [
707
+ {
708
+ name: "jira_get_ticket",
709
+ description: "Fetch a Jira ticket by its key (e.g., MODS-12115). Returns full details including description, comments, attachments, and linked Figma designs.",
710
+ inputSchema: {
711
+ type: "object",
712
+ properties: {
713
+ issueKey: {
714
+ type: "string",
715
+ description: "The Jira issue key (e.g., MODS-12115)",
716
+ },
717
+ downloadImages: {
718
+ type: "boolean",
719
+ description: "Download image attachments (default: true)",
720
+ },
721
+ fetchFigma: {
722
+ type: "boolean",
723
+ description: "Fetch linked Figma designs and export images (default: true)",
724
+ },
725
+ },
726
+ required: ["issueKey"],
727
+ },
728
+ },
729
+ {
730
+ name: "jira_search",
731
+ description: "Search Jira tickets using JQL. Examples: 'project = MODS AND status = Open'",
732
+ inputSchema: {
733
+ type: "object",
734
+ properties: {
735
+ jql: { type: "string", description: "JQL query string" },
736
+ maxResults: { type: "number", description: "Max results (default 10)" },
737
+ },
738
+ required: ["jql"],
739
+ },
740
+ },
741
+ ],
742
+ };
743
+ });
744
+
745
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
746
+ const { name, arguments: args } = request.params;
747
+
748
+ try {
749
+ if (name === "jira_get_ticket") {
750
+ const downloadImages = args.downloadImages !== false;
751
+ const fetchFigma = args.fetchFigma !== false;
752
+ const result = await getTicket(args.issueKey, downloadImages, fetchFigma);
753
+
754
+ const content = [{ type: "text", text: result.text }];
755
+
756
+ // Add Jira images
757
+ for (const imagePath of result.jiraImages) {
758
+ try {
759
+ const imageData = fs.readFileSync(imagePath);
760
+ const ext = path.extname(imagePath).toLowerCase();
761
+ const mimeType = ext === ".png" ? "image/png" :
762
+ ext === ".gif" ? "image/gif" :
763
+ ext === ".webp" ? "image/webp" : "image/jpeg";
764
+ content.push({
765
+ type: "image",
766
+ data: imageData.toString("base64"),
767
+ mimeType: mimeType,
768
+ });
769
+ } catch (e) { /* skip */ }
770
+ }
771
+
772
+ // Add Figma images (now supports multiple images per design)
773
+ for (const design of result.figmaDesigns) {
774
+ if (design.images && design.images.length > 0) {
775
+ for (const img of design.images) {
776
+ if (img.buffer) {
777
+ content.push({
778
+ type: "image",
779
+ data: img.buffer.toString("base64"),
780
+ mimeType: "image/png",
781
+ });
782
+ }
783
+ }
784
+ }
785
+ }
786
+
787
+ return { content };
788
+ } else if (name === "jira_search") {
789
+ const result = await searchTickets(args.jql, args.maxResults || 10);
790
+ return { content: [{ type: "text", text: result }] };
791
+ } else {
792
+ throw new Error(`Unknown tool: ${name}`);
793
+ }
794
+ } catch (error) {
795
+ return {
796
+ content: [{ type: "text", text: `Error: ${error.message}` }],
797
+ isError: true,
798
+ };
799
+ }
800
+ });
801
+
802
+ async function main() {
803
+ const transport = new StdioServerTransport();
804
+ await server.connect(transport);
805
+ }
806
+
807
+ main().catch(console.error);
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@rui.branco/jira-mcp",
3
+ "version": "1.0.0",
4
+ "description": "Jira MCP server for Claude Code - fetch tickets, search with JQL, and get Figma designs",
5
+ "main": "index.js",
6
+ "bin": {
7
+ "jira-mcp": "./index.js"
8
+ },
9
+ "files": [
10
+ "index.js",
11
+ "setup.js"
12
+ ],
13
+ "scripts": {
14
+ "setup": "node setup.js"
15
+ },
16
+ "keywords": [
17
+ "mcp",
18
+ "model-context-protocol",
19
+ "jira",
20
+ "claude",
21
+ "claude-code",
22
+ "atlassian"
23
+ ],
24
+ "author": "Rui Branco",
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/rui-branco/jira-mcp.git"
29
+ },
30
+ "dependencies": {
31
+ "@modelcontextprotocol/sdk": "^1.25.3",
32
+ "node-fetch": "^2.7.0"
33
+ }
34
+ }
package/setup.js ADDED
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env node
2
+
3
+ const readline = require("readline");
4
+ const fs = require("fs");
5
+ const path = require("path");
6
+
7
+ const configDir = path.join(process.env.HOME, ".config/jira-mcp");
8
+ const configPath = path.join(configDir, "config.json");
9
+
10
+ // Check for command line arguments
11
+ const args = process.argv.slice(2);
12
+ if (args.length >= 3) {
13
+ // Non-interactive mode: node setup.js <email> <token> <baseUrl>
14
+ const [email, token, baseUrl] = args;
15
+
16
+ if (!fs.existsSync(configDir)) {
17
+ fs.mkdirSync(configDir, { recursive: true });
18
+ }
19
+
20
+ fs.writeFileSync(
21
+ configPath,
22
+ JSON.stringify({ email, token, baseUrl: baseUrl.replace(/\/$/, "") }, null, 2)
23
+ );
24
+ console.log(`Config saved to ${configPath}`);
25
+ process.exit(0);
26
+ }
27
+
28
+ // Interactive mode
29
+ const rl = readline.createInterface({
30
+ input: process.stdin,
31
+ output: process.stdout,
32
+ });
33
+
34
+ function ask(question) {
35
+ return new Promise((resolve) => {
36
+ rl.question(question, resolve);
37
+ });
38
+ }
39
+
40
+ async function setup() {
41
+ console.log("\n=== Jira MCP Setup ===\n");
42
+ console.log("To get your Jira API token:");
43
+ console.log("1. Go to https://id.atlassian.com/manage-profile/security/api-tokens");
44
+ console.log("2. Click 'Create API token'");
45
+ console.log("3. Copy the token\n");
46
+
47
+ const email = await ask("Jira email: ");
48
+ const token = await ask("Jira API token: ");
49
+ const baseUrl = await ask("Jira base URL (e.g., https://company.atlassian.net): ");
50
+
51
+ if (!fs.existsSync(configDir)) {
52
+ fs.mkdirSync(configDir, { recursive: true });
53
+ }
54
+
55
+ fs.writeFileSync(
56
+ configPath,
57
+ JSON.stringify({ email, token, baseUrl: baseUrl.replace(/\/$/, "") }, null, 2)
58
+ );
59
+ console.log(`\nConfig saved to ${configPath}`);
60
+
61
+ // Check for Figma MCP
62
+ const figmaConfigPath = path.join(process.env.HOME, ".config/figma-mcp/config.json");
63
+ if (fs.existsSync(figmaConfigPath)) {
64
+ console.log("\n[OK] Figma MCP detected - Figma links in tickets will be fetched automatically");
65
+ } else {
66
+ console.log("\n[INFO] Figma MCP not installed - Figma links won't be fetched");
67
+ console.log("To enable Figma integration, install figma-mcp");
68
+ }
69
+
70
+ console.log("\n=== Setup Complete ===");
71
+ console.log("\nAdd this to your Claude Code config (~/.claude.json):\n");
72
+ console.log(`"mcpServers": {
73
+ "jira": {
74
+ "type": "stdio",
75
+ "command": "node",
76
+ "args": ["${path.join(configDir, "server/index.js")}"]
77
+ }
78
+ }`);
79
+
80
+ rl.close();
81
+ }
82
+
83
+ setup().catch((e) => {
84
+ console.error("Setup failed:", e.message);
85
+ rl.close();
86
+ process.exit(1);
87
+ });