@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.
- package/README.md +183 -0
- package/index.js +807 -0
- package/package.json +34 -0
- 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
|
+
});
|