@thammarongg/jira-mcp 0.1.0 → 0.3.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 +47 -11
- package/dist/index.js +4 -2
- package/dist/install.js +378 -116
- package/dist/search.js +56 -0
- package/dist/tools/epics.js +118 -29
- package/dist/tools/issues.js +18 -4
- package/dist/tools/projects.js +9 -2
- package/dist/tools/sprints.js +27 -17
- package/dist/util.js +0 -3
- package/package.json +2 -2
- package/skill/SKILL.md +31 -7
package/README.md
CHANGED
|
@@ -6,10 +6,42 @@ Works with both **Jira Cloud** (REST API v3) and **Jira Data Center** (REST API
|
|
|
6
6
|
|
|
7
7
|
## Install (one-click)
|
|
8
8
|
|
|
9
|
-
Once published to npm, any MCP client can run it via `npx` — no local build needed
|
|
9
|
+
Once published to npm, any MCP client can run it via `npx` — no local build needed.
|
|
10
|
+
|
|
11
|
+
### Interactive installer (all agents at once)
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx -y @thammarongg/jira-mcp install
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Shows a menu — Select All or pick agents (Claude Code, OpenCode, Codex, Cursor,
|
|
18
|
+
Claude Desktop, Gemini CLI) — then asks for the Jira base URL and credentials
|
|
19
|
+
(token entry is hidden on a TTY). Existing config files are backed up to
|
|
20
|
+
`.bak` before any modification, and re-running updates the `jira` entry in
|
|
21
|
+
place instead of duplicating it.
|
|
22
|
+
|
|
23
|
+
Non-interactive (CI / scripting):
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
# Jira Cloud
|
|
27
|
+
npx -y @thammarongg/jira-mcp install --agents all \
|
|
28
|
+
--base-url https://your-org.atlassian.net \
|
|
29
|
+
--email you@example.com --token xxx --yes
|
|
30
|
+
|
|
31
|
+
# Jira Data Center (subset of agents)
|
|
32
|
+
npx -y @thammarongg/jira-mcp install --agents claude-code,codex \
|
|
33
|
+
--base-url https://jira.yourcompany.com \
|
|
34
|
+
--username you --token xxx --yes
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Flags: `--agents all` or comma-separated ids/numbers (`claude-code`,
|
|
38
|
+
`opencode`, `codex`, `cursor`, `claude-desktop`, `gemini-cli`), `--base-url`,
|
|
39
|
+
`--email` (Cloud) / `--username` (Data Center), `--token` (API token or PAT),
|
|
40
|
+
`--password` (DC app password), `--yes` (skip the confirm prompt).
|
|
41
|
+
|
|
42
|
+
### Claude Code (manual one-liner)
|
|
10
43
|
|
|
11
44
|
```bash
|
|
12
|
-
# Claude Code (one-liner)
|
|
13
45
|
claude mcp add jira --env JIRA_BASE_URL=https://your-org.atlassian.net \
|
|
14
46
|
--env JIRA_EMAIL=you@example.com --env JIRA_API_TOKEN=xxx \
|
|
15
47
|
-- npx -y @thammarongg/jira-mcp
|
|
@@ -140,17 +172,18 @@ mkdir -p ~/.agents/skills/jira && cp skill/SKILL.md ~/.agents/skills/jira/
|
|
|
140
172
|
| `update_sprint` | Rename, reschedule, change goal/state |
|
|
141
173
|
| `close_sprint` | Close a sprint |
|
|
142
174
|
| `get_sprint_issues` | Issues in a sprint |
|
|
143
|
-
| `get_sprint_view` | Full UI-like sprint view (
|
|
144
|
-
| `get_backlog` | Board backlog
|
|
175
|
+
| `get_sprint_view` | Full UI-like sprint view (board + sprint + issues in one call) |
|
|
176
|
+
| `get_backlog` | Board backlog (Agile `/board/{id}/backlog`, rank-ordered) |
|
|
145
177
|
|
|
146
178
|
### Epics
|
|
147
179
|
|
|
148
180
|
| Tool | Description |
|
|
149
181
|
| --- | --- |
|
|
150
|
-
| `list_epics`
|
|
151
|
-
| `
|
|
152
|
-
| `
|
|
153
|
-
| `
|
|
182
|
+
| `list_epics` | Epics on a board (optionally filtered by `done`) |
|
|
183
|
+
| `get_epic` / `get_epic_issues` | Read an epic and its children (works on team-managed projects) |
|
|
184
|
+
| `create_epic` | New epic in a project |
|
|
185
|
+
| `move_issue_to_epic` | Add issues to an epic (sets `parent` on team-managed) |
|
|
186
|
+
| `get_epic_meta` | Epic-level issue types available in a project |
|
|
154
187
|
|
|
155
188
|
### Issues
|
|
156
189
|
|
|
@@ -160,7 +193,7 @@ mkdir -p ~/.agents/skills/jira && cp skill/SKILL.md ~/.agents/skills/jira/
|
|
|
160
193
|
| `create_issue` | Create (supports custom fields) |
|
|
161
194
|
| `update_issue` | Set fields and/or relative `update` ops |
|
|
162
195
|
| `delete_issue` | Delete |
|
|
163
|
-
| `search_issues` | **JQL search**
|
|
196
|
+
| `search_issues` | **JQL search** — enhanced search (`/search/jql`) on Cloud, legacy `/search` on Data Center |
|
|
164
197
|
| `get_issue_create_meta` | Discover projects/types/required fields |
|
|
165
198
|
| `get_issue_transitions` / `transition_issue` | Workflow transitions |
|
|
166
199
|
| `assign_issue` | Assign/unassign |
|
|
@@ -198,8 +231,11 @@ node scripts/smoke.mjs # stdio handshake + tools/list smoke test
|
|
|
198
231
|
## Notes & limitations
|
|
199
232
|
|
|
200
233
|
- Auth is HTTP Basic (email+token for Cloud, username+token/password for DC) — the standard for Jira REST.
|
|
201
|
-
- Pagination: list tools return Jira's native `startAt`/`maxResults`/`total`; pass `startAt` to page.
|
|
202
|
-
- `
|
|
234
|
+
- Pagination: most list tools return Jira's native `startAt`/`maxResults`/`total`; pass `startAt` to page.
|
|
235
|
+
- JQL search on **Jira Cloud** uses `/rest/api/3/search/jql`, since Atlassian removed `GET /rest/api/{2,3}/search` on 2025-05-01 ([CHANGE-2046](https://developer.atlassian.com/changelog/#CHANGE-2046) — the old endpoint now returns HTTP 410). Consequences for `search_issues` on Cloud: the JQL must be **bounded** (include a restriction such as `project`, `assignee`, or `key`), the response carries **no `total`**, and paging is by cursor — pass the returned `nextPageToken` back and stop when `isLast` is true. `startAt` is rejected there rather than silently ignored, and `includeApproximateTotal: true` adds an approximate match count via `/search/approximate-count`.
|
|
236
|
+
- Jira **Data Center** keeps the legacy `/search` endpoint with `startAt`/`total`; if a Cloud site on a custom domain is misdetected as DC, a 410 from `/search` transparently retries against `/search/jql`.
|
|
237
|
+
- `get_backlog` calls the Agile API's `/board/{id}/backlog` endpoint, so it keeps `startAt`/`total` paging on Cloud and DC alike; pass `jql` to narrow it further.
|
|
203
238
|
- Rapid view IDs are computed as `boardId * 10^13 + sprintId` (Jira's documented convention).
|
|
204
239
|
- Comment bodies use the `body` field on both Cloud (v3) and Data Center (v2).
|
|
240
|
+
- Epics: the Agile epic API (`/rest/agile/1.0/epic/...`) only understands company-managed epics and returns HTTP 400 on team-managed ("next-gen") projects. `get_epic`, `get_epic_issues`, and `move_issue_to_epic` detect that and fall back to the issue/search APIs, where an epic is an ordinary issue linked to its children by `parent`.
|
|
205
241
|
- `jira_api` paths must resolve under `/rest/` — paths that would escape it (e.g. via `..` segments) are rejected, and `?`/`#` must be passed via `query`.
|
package/dist/index.js
CHANGED
|
@@ -19,7 +19,7 @@ async function main() {
|
|
|
19
19
|
}
|
|
20
20
|
const config = loadConfig();
|
|
21
21
|
const client = new JiraClient(config);
|
|
22
|
-
const server = new McpServer({ name: "jira", version: "0.
|
|
22
|
+
const server = new McpServer({ name: "jira", version: "0.3.0" });
|
|
23
23
|
registerBoardTools(server, client);
|
|
24
24
|
registerSprintTools(server, client);
|
|
25
25
|
registerEpicTools(server, client);
|
|
@@ -33,6 +33,8 @@ async function main() {
|
|
|
33
33
|
console.error(`jira-mcp ready: ${config.baseUrl} (API v${config.apiVersion}, ${config.isCloud ? "Cloud" : "Data Center"})`);
|
|
34
34
|
}
|
|
35
35
|
main().catch((err) => {
|
|
36
|
-
|
|
36
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
37
|
+
const prefix = process.argv[2] === "install" ? "jira-mcp install failed" : "jira-mcp failed to start";
|
|
38
|
+
console.error(`${prefix}: ${msg}`);
|
|
37
39
|
process.exit(1);
|
|
38
40
|
});
|
package/dist/install.js
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { homedir, platform } from "node:os";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import * as readline from "node:readline";
|
|
5
|
-
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
function readPackageName() {
|
|
7
|
+
try {
|
|
8
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
9
|
+
const parsed = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
10
|
+
if (typeof parsed.name === "string" && parsed.name.length > 0)
|
|
11
|
+
return parsed.name;
|
|
12
|
+
}
|
|
13
|
+
catch { }
|
|
14
|
+
return "@thammarongg/jira-mcp";
|
|
15
|
+
}
|
|
16
|
+
const PKG = readPackageName();
|
|
6
17
|
const claudeDesktopPath = platform() === "darwin"
|
|
7
18
|
? join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json")
|
|
8
19
|
: join(homedir(), ".config", "Claude", "claude_desktop_config.json");
|
|
@@ -48,6 +59,37 @@ function parseFlags(argv) {
|
|
|
48
59
|
}
|
|
49
60
|
return flags;
|
|
50
61
|
}
|
|
62
|
+
function validateCredentialFlags(flags) {
|
|
63
|
+
const anyPresent = [flags.baseUrl, flags.email, flags.username, flags.token, flags.password].some((v) => v !== undefined);
|
|
64
|
+
if (!anyPresent)
|
|
65
|
+
return;
|
|
66
|
+
if (flags.email !== undefined && flags.username !== undefined)
|
|
67
|
+
throw new Error("--email and --username are mutually exclusive (Cloud uses --email, Data Center uses --username)");
|
|
68
|
+
if (flags.email !== undefined) {
|
|
69
|
+
const missing = [];
|
|
70
|
+
if (flags.baseUrl === undefined)
|
|
71
|
+
missing.push("--base-url");
|
|
72
|
+
if (flags.token === undefined)
|
|
73
|
+
missing.push("--token");
|
|
74
|
+
if (missing.length > 0)
|
|
75
|
+
throw new Error(`--email requires ${missing.join(" and ")}`);
|
|
76
|
+
if (flags.password !== undefined)
|
|
77
|
+
throw new Error("--password is a Data Center option and cannot be combined with --email");
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (flags.username !== undefined) {
|
|
81
|
+
if (flags.baseUrl === undefined)
|
|
82
|
+
throw new Error("--username requires --base-url");
|
|
83
|
+
if (flags.token === undefined && flags.password === undefined)
|
|
84
|
+
throw new Error("--username requires --token or --password");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (flags.token !== undefined)
|
|
88
|
+
throw new Error("--token requires --email (Cloud) or --username (Data Center)");
|
|
89
|
+
if (flags.password !== undefined)
|
|
90
|
+
throw new Error("--password requires --username (Data Center)");
|
|
91
|
+
throw new Error("--base-url requires --email or --username (plus --token / --password)");
|
|
92
|
+
}
|
|
51
93
|
function printUsage() {
|
|
52
94
|
console.log(`jira-mcp install — configure the jira MCP server in your agent(s)
|
|
53
95
|
|
|
@@ -66,40 +108,187 @@ Options:
|
|
|
66
108
|
-h, --help Show this help
|
|
67
109
|
`);
|
|
68
110
|
}
|
|
111
|
+
function makePrompter() {
|
|
112
|
+
const queue = [];
|
|
113
|
+
const waiters = [];
|
|
114
|
+
let closed = false;
|
|
115
|
+
let suspended = false;
|
|
116
|
+
let rl = null;
|
|
117
|
+
const attach = (r) => {
|
|
118
|
+
r.on("line", (line) => {
|
|
119
|
+
const waiter = waiters.shift();
|
|
120
|
+
if (waiter)
|
|
121
|
+
waiter(line);
|
|
122
|
+
else
|
|
123
|
+
queue.push(line);
|
|
124
|
+
});
|
|
125
|
+
r.on("close", () => {
|
|
126
|
+
if (suspended)
|
|
127
|
+
return;
|
|
128
|
+
closed = true;
|
|
129
|
+
while (waiters.length > 0)
|
|
130
|
+
waiters.shift()("");
|
|
131
|
+
});
|
|
132
|
+
};
|
|
133
|
+
const start = () => {
|
|
134
|
+
rl = readline.createInterface({
|
|
135
|
+
input: process.stdin,
|
|
136
|
+
output: process.stdout,
|
|
137
|
+
terminal: process.stdin.isTTY === true,
|
|
138
|
+
});
|
|
139
|
+
attach(rl);
|
|
140
|
+
};
|
|
141
|
+
start();
|
|
142
|
+
const prompter = {
|
|
143
|
+
get closed() {
|
|
144
|
+
return closed;
|
|
145
|
+
},
|
|
146
|
+
ask(question) {
|
|
147
|
+
process.stdout.write(question);
|
|
148
|
+
const queued = queue.shift();
|
|
149
|
+
if (queued !== undefined)
|
|
150
|
+
return Promise.resolve(queued);
|
|
151
|
+
if (closed)
|
|
152
|
+
return Promise.resolve("");
|
|
153
|
+
return new Promise((resolve) => waiters.push(resolve));
|
|
154
|
+
},
|
|
155
|
+
close() {
|
|
156
|
+
suspended = false;
|
|
157
|
+
closed = true;
|
|
158
|
+
while (waiters.length > 0)
|
|
159
|
+
waiters.shift()("");
|
|
160
|
+
rl?.close();
|
|
161
|
+
rl = null;
|
|
162
|
+
},
|
|
163
|
+
suspend() {
|
|
164
|
+
suspended = true;
|
|
165
|
+
rl?.close();
|
|
166
|
+
rl = null;
|
|
167
|
+
},
|
|
168
|
+
resume() {
|
|
169
|
+
suspended = false;
|
|
170
|
+
if (rl === null)
|
|
171
|
+
start();
|
|
172
|
+
},
|
|
173
|
+
tryQueued() {
|
|
174
|
+
return queue.shift();
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
return prompter;
|
|
178
|
+
}
|
|
179
|
+
function askPassword(p, promptText) {
|
|
180
|
+
const stdin = process.stdin;
|
|
181
|
+
if (typeof stdin.setRawMode !== "function") {
|
|
182
|
+
return p.ask(`${promptText} (no echo available) `).then((v) => v.trim());
|
|
183
|
+
}
|
|
184
|
+
const queued = p.tryQueued();
|
|
185
|
+
if (queued !== undefined) {
|
|
186
|
+
process.stdout.write(promptText);
|
|
187
|
+
return Promise.resolve(queued.trim());
|
|
188
|
+
}
|
|
189
|
+
return new Promise((resolve, reject) => {
|
|
190
|
+
let value = "";
|
|
191
|
+
let finished = false;
|
|
192
|
+
const cleanup = () => {
|
|
193
|
+
if (finished)
|
|
194
|
+
return;
|
|
195
|
+
finished = true;
|
|
196
|
+
stdin.removeListener("data", onData);
|
|
197
|
+
stdin.removeListener("end", onEnd);
|
|
198
|
+
stdin.removeListener("close", onClose);
|
|
199
|
+
stdin.setRawMode(false);
|
|
200
|
+
};
|
|
201
|
+
const finish = (v) => {
|
|
202
|
+
cleanup();
|
|
203
|
+
p.resume();
|
|
204
|
+
if (v === null)
|
|
205
|
+
reject(new Error("input closed while reading password"));
|
|
206
|
+
else {
|
|
207
|
+
process.stdout.write("\n");
|
|
208
|
+
resolve(v.trim());
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
const onData = (buf) => {
|
|
212
|
+
for (const ch of buf.toString("utf8")) {
|
|
213
|
+
if (ch === "\r" || ch === "\n") {
|
|
214
|
+
finish(value);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (ch === "\x03") {
|
|
218
|
+
cleanup();
|
|
219
|
+
p.resume();
|
|
220
|
+
process.exit(130);
|
|
221
|
+
}
|
|
222
|
+
if (ch === "\x04") {
|
|
223
|
+
finish("");
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (ch === "\u007f" || ch === "\b") {
|
|
227
|
+
if (value.length > 0) {
|
|
228
|
+
value = value.slice(0, -1);
|
|
229
|
+
process.stdout.write("\b \b");
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
else if (ch >= " ") {
|
|
233
|
+
value += ch;
|
|
234
|
+
process.stdout.write("*");
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
const onEnd = () => finish(null);
|
|
239
|
+
const onClose = () => finish(null);
|
|
240
|
+
p.suspend();
|
|
241
|
+
process.stdout.write(promptText);
|
|
242
|
+
stdin.setRawMode(true);
|
|
243
|
+
stdin.resume();
|
|
244
|
+
stdin.on("data", onData);
|
|
245
|
+
stdin.on("end", onEnd);
|
|
246
|
+
stdin.on("close", onClose);
|
|
247
|
+
});
|
|
248
|
+
}
|
|
69
249
|
export async function runInstaller(argv) {
|
|
70
250
|
const flags = parseFlags(argv);
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
if (
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
for (const agent of selected) {
|
|
90
|
-
try {
|
|
91
|
-
const result = writeAgentConfig(agent, env);
|
|
92
|
-
console.log(` ok ${agent.label.padEnd(15)} ${agent.path} (${result})`);
|
|
251
|
+
validateCredentialFlags(flags);
|
|
252
|
+
const hasCredentials = (flags.baseUrl && flags.email && flags.token) ||
|
|
253
|
+
(flags.baseUrl && flags.username && (flags.token || flags.password));
|
|
254
|
+
const needsPrompts = !flags.agents || !hasCredentials || !flags.yes;
|
|
255
|
+
const p = needsPrompts ? makePrompter() : undefined;
|
|
256
|
+
try {
|
|
257
|
+
const selected = flags.agents ? resolveAgents(flags.agents) : await promptSelection(p);
|
|
258
|
+
const env = hasCredentials ? buildEnvFromFlags(flags) : await promptCredentials(p);
|
|
259
|
+
console.log("\nWill configure the jira MCP server in:");
|
|
260
|
+
for (const agent of selected)
|
|
261
|
+
console.log(` - ${agent.label.padEnd(15)} ${agent.path}`);
|
|
262
|
+
console.log(`Env vars: ${Object.keys(env).join(", ")} (values not shown)`);
|
|
263
|
+
if (!flags.yes) {
|
|
264
|
+
const answer = (await p.ask("\nProceed? [Y/n] ")).trim().toLowerCase();
|
|
265
|
+
if ((p.closed && answer === "") || (answer !== "" && answer !== "y" && answer !== "yes")) {
|
|
266
|
+
console.log("Aborted.");
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
93
269
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
270
|
+
let failed = false;
|
|
271
|
+
for (const agent of selected) {
|
|
272
|
+
try {
|
|
273
|
+
const result = writeAgentConfig(agent, env);
|
|
274
|
+
if (result === "skipped")
|
|
275
|
+
console.log(` skip ${agent.label.padEnd(15)} ${agent.path}`);
|
|
276
|
+
else
|
|
277
|
+
console.log(` ok ${agent.label.padEnd(15)} ${agent.path} (${result})`);
|
|
278
|
+
}
|
|
279
|
+
catch (err) {
|
|
280
|
+
failed = true;
|
|
281
|
+
console.error(` ERR ${agent.label.padEnd(15)} ${agent.path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
282
|
+
}
|
|
97
283
|
}
|
|
284
|
+
console.log("\nDone. Restart each agent to pick up the new MCP server.");
|
|
285
|
+
console.log("Verify by asking your agent to call the get_current_user tool.");
|
|
286
|
+
if (failed)
|
|
287
|
+
process.exitCode = 1;
|
|
288
|
+
}
|
|
289
|
+
finally {
|
|
290
|
+
p?.close();
|
|
98
291
|
}
|
|
99
|
-
console.log("\nDone. Restart each agent to pick up the new MCP server.");
|
|
100
|
-
console.log("Verify by asking your agent to call the get_current_user tool.");
|
|
101
|
-
if (failed)
|
|
102
|
-
process.exitCode = 1;
|
|
103
292
|
}
|
|
104
293
|
function resolveAgents(spec) {
|
|
105
294
|
const norm = spec.trim().toLowerCase();
|
|
@@ -107,9 +296,9 @@ function resolveAgents(spec) {
|
|
|
107
296
|
return [...AGENTS];
|
|
108
297
|
const ids = norm.split(/[,\s]+/).filter(Boolean);
|
|
109
298
|
const selected = ids.map((id) => {
|
|
110
|
-
const agent = AGENTS.find((a) => a.id === id);
|
|
299
|
+
const agent = /^\d+$/.test(id) ? AGENTS[Number(id) - 1] : AGENTS.find((a) => a.id === id);
|
|
111
300
|
if (!agent)
|
|
112
|
-
throw new Error(`Unknown agent '${id}'. Valid: ${AGENTS.map((a) => a.id).join(", ")} or all`);
|
|
301
|
+
throw new Error(`Unknown agent '${id}'. Valid: ${AGENTS.map((a, i) => `${i + 1}/${a.id}`).join(", ")} or all`);
|
|
113
302
|
return agent;
|
|
114
303
|
});
|
|
115
304
|
if (selected.length === 0)
|
|
@@ -119,14 +308,10 @@ function resolveAgents(spec) {
|
|
|
119
308
|
function buildEnvFromFlags(flags) {
|
|
120
309
|
const env = { JIRA_BASE_URL: flags.baseUrl };
|
|
121
310
|
if (flags.email) {
|
|
122
|
-
if (!flags.token)
|
|
123
|
-
throw new Error("--email requires --token");
|
|
124
311
|
env.JIRA_EMAIL = flags.email;
|
|
125
312
|
env.JIRA_API_TOKEN = flags.token;
|
|
126
313
|
}
|
|
127
314
|
else {
|
|
128
|
-
if (!flags.token && !flags.password)
|
|
129
|
-
throw new Error("--username requires --token or --password");
|
|
130
315
|
env.JIRA_USERNAME = flags.username;
|
|
131
316
|
if (flags.token)
|
|
132
317
|
env.JIRA_API_TOKEN = flags.token;
|
|
@@ -135,84 +320,37 @@ function buildEnvFromFlags(flags) {
|
|
|
135
320
|
}
|
|
136
321
|
return env;
|
|
137
322
|
}
|
|
138
|
-
function
|
|
139
|
-
return readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
140
|
-
}
|
|
141
|
-
function ask(rl, question) {
|
|
142
|
-
return new Promise((resolve) => rl.question(question, resolve));
|
|
143
|
-
}
|
|
144
|
-
function askPassword(promptText) {
|
|
145
|
-
const stdin = process.stdin;
|
|
146
|
-
if (typeof stdin.setRawMode !== "function") {
|
|
147
|
-
return new Promise((resolve) => {
|
|
148
|
-
process.stdout.write(`${promptText} (non-interactive: value visible)\n`);
|
|
149
|
-
const rl = makeRl();
|
|
150
|
-
rl.question("", (v) => {
|
|
151
|
-
rl.close();
|
|
152
|
-
resolve(v.trim());
|
|
153
|
-
});
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
return new Promise((resolve) => {
|
|
157
|
-
let value = "";
|
|
158
|
-
const done = () => {
|
|
159
|
-
stdin.removeListener("data", onData);
|
|
160
|
-
stdin.setRawMode(false);
|
|
161
|
-
process.stdout.write("\n");
|
|
162
|
-
resolve(value);
|
|
163
|
-
};
|
|
164
|
-
const onData = (buf) => {
|
|
165
|
-
for (const ch of buf.toString("utf8")) {
|
|
166
|
-
if (ch === "\r" || ch === "\n") {
|
|
167
|
-
done();
|
|
168
|
-
return;
|
|
169
|
-
}
|
|
170
|
-
if (ch === "\u007f" || ch === "\b")
|
|
171
|
-
value = value.slice(0, -1);
|
|
172
|
-
else if (ch >= " ")
|
|
173
|
-
value += ch;
|
|
174
|
-
}
|
|
175
|
-
};
|
|
176
|
-
process.stdout.write(promptText);
|
|
177
|
-
stdin.setRawMode(true);
|
|
178
|
-
stdin.on("data", onData);
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
async function promptSelection() {
|
|
323
|
+
async function promptSelection(p) {
|
|
182
324
|
console.log(`\njira-mcp installer — select agents to configure:\n`);
|
|
183
325
|
console.log(" a) Select All");
|
|
184
326
|
AGENTS.forEach((agent, i) => {
|
|
185
327
|
console.log(` ${i + 1}) ${agent.label.padEnd(15)} ${agent.path}`);
|
|
186
328
|
});
|
|
187
|
-
const rl = makeRl();
|
|
188
329
|
for (;;) {
|
|
189
|
-
const answer = (await ask(
|
|
190
|
-
|
|
330
|
+
const answer = (await p.ask("\nChoice (e.g. 1,3 or a): ")).trim().toLowerCase();
|
|
331
|
+
if (answer === "" && p.closed)
|
|
332
|
+
throw new Error("No input received (stdin closed)");
|
|
191
333
|
try {
|
|
192
334
|
return answer === "a" || answer === "all" ? [...AGENTS] : resolveAgents(answer);
|
|
193
335
|
}
|
|
194
336
|
catch (err) {
|
|
337
|
+
if (p.closed)
|
|
338
|
+
throw err;
|
|
195
339
|
console.log(` ${err instanceof Error ? err.message : String(err)}`);
|
|
196
340
|
}
|
|
197
341
|
}
|
|
198
342
|
}
|
|
199
|
-
async function promptCredentials() {
|
|
200
|
-
const
|
|
201
|
-
const baseUrl = (await ask(rl, "Jira base URL (e.g. https://your-org.atlassian.net): ")).trim();
|
|
343
|
+
async function promptCredentials(p) {
|
|
344
|
+
const baseUrl = (await p.ask("Jira base URL (e.g. https://your-org.atlassian.net): ")).trim();
|
|
202
345
|
if (!/^https?:\/\/.+/i.test(baseUrl)) {
|
|
203
|
-
rl.close();
|
|
204
346
|
throw new Error("Jira base URL must start with http(s)://");
|
|
205
347
|
}
|
|
206
|
-
const mode = (await ask(
|
|
348
|
+
const mode = (await p.ask("Deployment: [1] Jira Cloud [2] Jira Data Center (default 1): ")).trim();
|
|
207
349
|
const isCloud = mode === "" || mode === "1";
|
|
208
|
-
const user = (await ask(
|
|
209
|
-
if (!user)
|
|
210
|
-
rl.close();
|
|
350
|
+
const user = (await p.ask(isCloud ? "Atlassian email: " : "Data Center username: ")).trim();
|
|
351
|
+
if (!user)
|
|
211
352
|
throw new Error(isCloud ? "Email is required" : "Username is required");
|
|
212
|
-
|
|
213
|
-
const secret = await askPassword(isCloud ? "API token (hidden, from id.atlassian.com): " : "API token / app password (hidden): ");
|
|
214
|
-
rl.resume();
|
|
215
|
-
rl.close();
|
|
353
|
+
const secret = await askPassword(p, isCloud ? "API token (masked, from id.atlassian.com): " : "API token / app password (masked): ");
|
|
216
354
|
if (!secret)
|
|
217
355
|
throw new Error("API token is required");
|
|
218
356
|
const env = { JIRA_BASE_URL: baseUrl };
|
|
@@ -230,8 +368,14 @@ function writeAgentConfig(agent, env) {
|
|
|
230
368
|
if (agent.kind === "codex")
|
|
231
369
|
return upsertCodex(agent.path, env);
|
|
232
370
|
if (agent.kind === "opencode") {
|
|
371
|
+
const jsonc = agent.path.replace(/\.json$/, ".jsonc");
|
|
372
|
+
if (existsSync(jsonc)) {
|
|
373
|
+
console.log(` ! ${jsonc} exists — skipping automatic opencode write (JSONC may contain comments); merge the jira entry manually`);
|
|
374
|
+
return "skipped";
|
|
375
|
+
}
|
|
233
376
|
return mergeJsonFile(agent.path, (obj) => {
|
|
234
377
|
const mcp = (obj.mcp ??= {});
|
|
378
|
+
reportEnvChanges(envOf(mcp.jira), env, agent.path);
|
|
235
379
|
mcp.jira = {
|
|
236
380
|
type: "local",
|
|
237
381
|
command: ["npx", "-y", PKG],
|
|
@@ -242,31 +386,122 @@ function writeAgentConfig(agent, env) {
|
|
|
242
386
|
}
|
|
243
387
|
return mergeJsonFile(agent.path, (obj) => {
|
|
244
388
|
const mcpServers = (obj.mcpServers ??= {});
|
|
389
|
+
reportEnvChanges(envOf(mcpServers.jira), env, agent.path);
|
|
245
390
|
mcpServers.jira = { command: "npx", args: ["-y", PKG], env };
|
|
246
391
|
});
|
|
247
392
|
}
|
|
393
|
+
function envOf(entry) {
|
|
394
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry))
|
|
395
|
+
return undefined;
|
|
396
|
+
const e = entry.env ?? entry.environment;
|
|
397
|
+
if (!e || typeof e !== "object" || Array.isArray(e))
|
|
398
|
+
return undefined;
|
|
399
|
+
return e;
|
|
400
|
+
}
|
|
401
|
+
function reportEnvChanges(oldEnv, newEnv, path) {
|
|
402
|
+
if (!oldEnv)
|
|
403
|
+
return;
|
|
404
|
+
const replaced = Object.keys(newEnv).filter((k) => oldEnv[k] !== newEnv[k]);
|
|
405
|
+
const removed = Object.keys(oldEnv).filter((k) => !(k in newEnv));
|
|
406
|
+
if (replaced.length > 0)
|
|
407
|
+
console.log(` ! ${path}: existing jira entry will be replaced — env vars changed: ${replaced.join(", ")}`);
|
|
408
|
+
if (removed.length > 0)
|
|
409
|
+
console.log(` ! ${path}: existing jira entry will be replaced — env vars removed: ${removed.join(", ")}`);
|
|
410
|
+
}
|
|
411
|
+
function fileStamp(path) {
|
|
412
|
+
const s = statSync(path);
|
|
413
|
+
return { mtimeMs: s.mtimeMs, size: s.size };
|
|
414
|
+
}
|
|
415
|
+
function assertUnchanged(path, before) {
|
|
416
|
+
const after = statSync(path);
|
|
417
|
+
if (after.mtimeMs !== before.mtimeMs || after.size !== before.size) {
|
|
418
|
+
throw new Error("changed while installing — close running agents and retry");
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
function atomicWrite(path, content) {
|
|
422
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
423
|
+
writeFileSync(tmp, content);
|
|
424
|
+
renameSync(tmp, path);
|
|
425
|
+
}
|
|
248
426
|
function mergeJsonFile(path, mutate) {
|
|
249
427
|
const existed = existsSync(path);
|
|
428
|
+
let stamp;
|
|
250
429
|
let obj = {};
|
|
251
430
|
if (existed) {
|
|
431
|
+
stamp = fileStamp(path);
|
|
252
432
|
const raw = readFileSync(path, "utf8");
|
|
433
|
+
let parsed;
|
|
253
434
|
try {
|
|
254
|
-
|
|
255
|
-
if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
|
|
256
|
-
obj = parsed;
|
|
435
|
+
parsed = raw.trim() ? JSON.parse(raw) : {};
|
|
257
436
|
}
|
|
258
437
|
catch {
|
|
259
|
-
|
|
260
|
-
|
|
438
|
+
parsed = undefined;
|
|
439
|
+
}
|
|
440
|
+
if (parsed === undefined || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
441
|
+
console.log(` ! ${path} is not a JSON object — backing up to ${path}.bak and starting fresh`);
|
|
442
|
+
}
|
|
443
|
+
else {
|
|
444
|
+
obj = parsed;
|
|
261
445
|
}
|
|
262
446
|
}
|
|
263
447
|
mutate(obj);
|
|
264
448
|
mkdirSync(dirname(path), { recursive: true });
|
|
265
|
-
|
|
449
|
+
if (existed) {
|
|
450
|
+
assertUnchanged(path, stamp);
|
|
451
|
+
copyFileSync(path, `${path}.bak`);
|
|
452
|
+
}
|
|
453
|
+
atomicWrite(path, JSON.stringify(obj, null, 2) + "\n");
|
|
266
454
|
return existed ? "updated" : "created";
|
|
267
455
|
}
|
|
268
456
|
function tomlStr(v) {
|
|
269
|
-
|
|
457
|
+
let out = "";
|
|
458
|
+
for (const ch of v) {
|
|
459
|
+
const code = ch.codePointAt(0);
|
|
460
|
+
if (ch === "\\")
|
|
461
|
+
out += "\\\\";
|
|
462
|
+
else if (ch === '"')
|
|
463
|
+
out += '\\"';
|
|
464
|
+
else if (ch === "\n")
|
|
465
|
+
out += "\\n";
|
|
466
|
+
else if (ch === "\t")
|
|
467
|
+
out += "\\t";
|
|
468
|
+
else if (ch === "\r")
|
|
469
|
+
out += "\\r";
|
|
470
|
+
else if (code < 0x20)
|
|
471
|
+
out += `\\u${code.toString(16).toUpperCase().padStart(4, "0")}`;
|
|
472
|
+
else
|
|
473
|
+
out += ch;
|
|
474
|
+
}
|
|
475
|
+
return `"${out}"`;
|
|
476
|
+
}
|
|
477
|
+
const JIRA_HEADER = /^\s*\[mcp_servers\.jira\](\s+#.*)?$/;
|
|
478
|
+
function parseTomlEnv(lines, start) {
|
|
479
|
+
let end = lines.length;
|
|
480
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
481
|
+
if (/^\s*\[/.test(lines[i])) {
|
|
482
|
+
end = i;
|
|
483
|
+
break;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
for (let i = start + 1; i < end; i++) {
|
|
487
|
+
const m = lines[i].match(/^\s*env\s*=\s*\{(.*)\}\s*$/);
|
|
488
|
+
if (!m)
|
|
489
|
+
continue;
|
|
490
|
+
const out = {};
|
|
491
|
+
for (const part of m[1].split(",")) {
|
|
492
|
+
const eq = part.indexOf("=");
|
|
493
|
+
if (eq < 0)
|
|
494
|
+
continue;
|
|
495
|
+
const k = part.slice(0, eq).trim();
|
|
496
|
+
let v = part.slice(eq + 1).trim();
|
|
497
|
+
if (v.length >= 2 && v.startsWith('"') && v.endsWith('"'))
|
|
498
|
+
v = v.slice(1, -1);
|
|
499
|
+
if (k)
|
|
500
|
+
out[k] = v;
|
|
501
|
+
}
|
|
502
|
+
return out;
|
|
503
|
+
}
|
|
504
|
+
return undefined;
|
|
270
505
|
}
|
|
271
506
|
function upsertCodex(path, env) {
|
|
272
507
|
const block = [
|
|
@@ -279,24 +514,51 @@ function upsertCodex(path, env) {
|
|
|
279
514
|
];
|
|
280
515
|
if (!existsSync(path)) {
|
|
281
516
|
mkdirSync(dirname(path), { recursive: true });
|
|
282
|
-
|
|
517
|
+
atomicWrite(path, block.join("\n") + "\n");
|
|
283
518
|
return "created";
|
|
284
519
|
}
|
|
520
|
+
const stamp = fileStamp(path);
|
|
285
521
|
const raw = readFileSync(path, "utf8");
|
|
286
|
-
const
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
522
|
+
const eol = raw.includes("\r\n") ? "\r\n" : "\n";
|
|
523
|
+
const lines = raw.split("\n").map((l) => (l.endsWith("\r") ? l.slice(0, -1) : l));
|
|
524
|
+
const starts = [];
|
|
525
|
+
for (let i = 0; i < lines.length; i++) {
|
|
526
|
+
if (JIRA_HEADER.test(lines[i]))
|
|
527
|
+
starts.push(i);
|
|
528
|
+
}
|
|
529
|
+
let content;
|
|
530
|
+
if (starts.length === 0) {
|
|
531
|
+
content = raw.replace(/\s*$/, "") + eol + eol + block.join(eol) + eol;
|
|
532
|
+
}
|
|
533
|
+
else {
|
|
534
|
+
const remove = new Set();
|
|
535
|
+
for (const start of starts) {
|
|
536
|
+
let end = lines.length;
|
|
537
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
538
|
+
if (/^\s*\[/.test(lines[i])) {
|
|
539
|
+
end = i;
|
|
540
|
+
break;
|
|
541
|
+
}
|
|
294
542
|
}
|
|
543
|
+
for (let i = start; i < end; i++)
|
|
544
|
+
remove.add(i);
|
|
295
545
|
}
|
|
296
|
-
lines.
|
|
297
|
-
|
|
298
|
-
|
|
546
|
+
const kept = lines.filter((_, i) => !remove.has(i));
|
|
547
|
+
let insertIdx = 0;
|
|
548
|
+
for (let i = 0; i < starts[0]; i++)
|
|
549
|
+
if (!remove.has(i))
|
|
550
|
+
insertIdx++;
|
|
551
|
+
kept.splice(insertIdx, 0, ...block);
|
|
552
|
+
const after = insertIdx + block.length;
|
|
553
|
+
if (after < kept.length && kept[after].trim() !== "")
|
|
554
|
+
kept.splice(after, 0, "");
|
|
555
|
+
content = kept.join(eol);
|
|
556
|
+
if (!content.endsWith(eol))
|
|
557
|
+
content += eol;
|
|
299
558
|
}
|
|
300
|
-
|
|
559
|
+
reportEnvChanges(starts.length > 0 ? parseTomlEnv(lines, starts[0]) : undefined, env, path);
|
|
560
|
+
assertUnchanged(path, stamp);
|
|
561
|
+
copyFileSync(path, `${path}.bak`);
|
|
562
|
+
atomicWrite(path, content);
|
|
301
563
|
return "updated";
|
|
302
564
|
}
|
package/dist/search.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { JiraApiError } from "./client.js";
|
|
2
|
+
/**
|
|
3
|
+
* Jira Cloud removed GET /rest/api/{2,3}/search on 2025-05-01 (CHANGE-2046); it now
|
|
4
|
+
* answers HTTP 410. The replacement, /search/jql ("enhanced search"), differs in three
|
|
5
|
+
* ways that matter to callers: it pages with an opaque nextPageToken instead of startAt,
|
|
6
|
+
* it returns no total, and it rejects unbounded JQL. Jira Data Center still only has the
|
|
7
|
+
* legacy endpoint, so both live here behind one call.
|
|
8
|
+
*/
|
|
9
|
+
export class UnsupportedParameterError extends Error {
|
|
10
|
+
constructor(message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "UnsupportedParameter";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export async function searchIssues(client, params) {
|
|
16
|
+
if (!client.isCloud) {
|
|
17
|
+
try {
|
|
18
|
+
return await legacySearch(client, params);
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
// A Cloud site on a custom domain looks like Data Center to loadConfig, so let the
|
|
22
|
+
// removal response itself route us to the new endpoint.
|
|
23
|
+
if (!(err instanceof JiraApiError && err.status === 410))
|
|
24
|
+
throw err;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return enhancedSearch(client, params);
|
|
28
|
+
}
|
|
29
|
+
function legacySearch(client, params) {
|
|
30
|
+
return client.apiGet("/search", {
|
|
31
|
+
jql: params.jql,
|
|
32
|
+
fields: params.fields,
|
|
33
|
+
maxResults: params.maxResults,
|
|
34
|
+
startAt: params.startAt ?? 0,
|
|
35
|
+
expand: params.expand,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
async function enhancedSearch(client, params) {
|
|
39
|
+
if (params.startAt !== undefined && params.startAt > 0) {
|
|
40
|
+
// /search/jql accepts startAt and silently ignores it, which would hand back page 1
|
|
41
|
+
// forever. Refusing is better than looping over the same issues.
|
|
42
|
+
throw new UnsupportedParameterError("Jira Cloud's enhanced search (/search/jql) pages with a cursor, not an offset: startAt is ignored. " +
|
|
43
|
+
"Omit startAt and pass nextPageToken from the previous response to get the next page.");
|
|
44
|
+
}
|
|
45
|
+
const page = await client.apiGet("/search/jql", {
|
|
46
|
+
jql: params.jql,
|
|
47
|
+
fields: params.fields,
|
|
48
|
+
maxResults: params.maxResults,
|
|
49
|
+
nextPageToken: params.nextPageToken,
|
|
50
|
+
expand: params.expand,
|
|
51
|
+
});
|
|
52
|
+
if (!params.includeApproximateTotal || !page || typeof page !== "object")
|
|
53
|
+
return page;
|
|
54
|
+
const counted = (await client.apiPost("/search/approximate-count", { jql: params.jql }));
|
|
55
|
+
return { ...page, approximateTotal: counted?.count };
|
|
56
|
+
}
|
package/dist/tools/epics.js
CHANGED
|
@@ -1,61 +1,150 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
2
|
+
import { JiraApiError } from "../client.js";
|
|
3
|
+
import { run } from "../util.js";
|
|
4
|
+
import { searchIssues } from "../search.js";
|
|
5
|
+
const DEFAULT_EPIC_ISSUE_FIELDS = "key,summary,status,assignee";
|
|
6
|
+
/**
|
|
7
|
+
* The Agile epic endpoints (/rest/agile/1.0/epic/...) only understand classic
|
|
8
|
+
* (company-managed) epics: on a team-managed ("next-gen") project they answer
|
|
9
|
+
* HTTP 400 "The request contains a next-gen issue". There, an epic is an ordinary
|
|
10
|
+
* issue and its children are linked by `parent`, so each tool falls back to the
|
|
11
|
+
* plain issue/search APIs when the Agile call rejects the project style.
|
|
12
|
+
*/
|
|
13
|
+
function isNextGenRejection(err) {
|
|
14
|
+
return err instanceof JiraApiError && (err.status === 400 || err.status === 404);
|
|
15
|
+
}
|
|
16
|
+
async function withNextGenFallback(agile, fallback) {
|
|
17
|
+
try {
|
|
18
|
+
return await agile();
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
if (!isNextGenRejection(err))
|
|
22
|
+
throw err;
|
|
23
|
+
return fallback();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
3
26
|
export function registerEpicTools(server, client) {
|
|
4
27
|
server.registerTool("list_epics", {
|
|
5
28
|
title: "List epics",
|
|
6
|
-
description: "List epics
|
|
29
|
+
description: "List the epics on a board, including whether each one is done.",
|
|
7
30
|
inputSchema: {
|
|
8
31
|
boardId: z.number().int().describe("Board ID"),
|
|
9
|
-
|
|
32
|
+
done: z.boolean().optional().describe("Filter by completion state; omit for all epics"),
|
|
10
33
|
maxResults: z.number().int().min(1).max(1000).default(100),
|
|
11
34
|
startAt: z.number().int().min(0).default(0),
|
|
12
35
|
},
|
|
13
|
-
}, async ({ boardId,
|
|
36
|
+
}, async ({ boardId, done, maxResults, startAt }) => run(() => client.agileGet(`/board/${boardId}/epic`, {
|
|
37
|
+
done: done === undefined ? undefined : String(done),
|
|
38
|
+
maxResults,
|
|
39
|
+
startAt,
|
|
40
|
+
})));
|
|
14
41
|
server.registerTool("get_epic", {
|
|
15
42
|
title: "Get epic",
|
|
16
|
-
description: "Get a single epic by
|
|
43
|
+
description: "Get a single epic by key (e.g. PROJ-1) or numeric issue ID. Falls back to the issue API for team-managed projects, where epics are ordinary issues.",
|
|
17
44
|
inputSchema: {
|
|
18
|
-
|
|
45
|
+
epicIdOrKey: z.string().describe("Epic key, e.g. PROJ-1, or numeric issue ID"),
|
|
19
46
|
},
|
|
20
|
-
}, async ({
|
|
47
|
+
}, async ({ epicIdOrKey }) => run(() => {
|
|
48
|
+
const id = encodeURIComponent(epicIdOrKey);
|
|
49
|
+
return withNextGenFallback(() => client.agileGet(`/epic/${id}`), () => client.apiGet(`/issue/${id}`, { fields: "summary,status,assignee,issuetype,project,description" }));
|
|
50
|
+
}));
|
|
21
51
|
server.registerTool("get_epic_issues", {
|
|
22
52
|
title: "Get epic issues",
|
|
23
|
-
description: "List the issues belonging to an epic.",
|
|
53
|
+
description: "List the issues belonging to an epic. Falls back to a JQL 'parent = <epic>' search on team-managed projects, where the Agile epic API does not apply.",
|
|
24
54
|
inputSchema: {
|
|
25
|
-
|
|
26
|
-
|
|
55
|
+
epicIdOrKey: z.string().describe("Epic key, e.g. PROJ-1, or numeric issue ID"),
|
|
56
|
+
fields: z
|
|
57
|
+
.string()
|
|
58
|
+
.optional()
|
|
59
|
+
.describe(`Comma-separated issue fields (default: ${DEFAULT_EPIC_ISSUE_FIELDS})`),
|
|
60
|
+
maxResults: z.number().int().min(1).max(100).default(100),
|
|
27
61
|
startAt: z.number().int().min(0).default(0),
|
|
62
|
+
nextPageToken: z
|
|
63
|
+
.string()
|
|
64
|
+
.optional()
|
|
65
|
+
.describe("Jira Cloud only: cursor returned by a previous fallback search"),
|
|
28
66
|
},
|
|
29
|
-
}, async ({
|
|
67
|
+
}, async ({ epicIdOrKey, fields, maxResults, startAt, nextPageToken }) => run(() => {
|
|
68
|
+
const issueFields = fields ?? DEFAULT_EPIC_ISSUE_FIELDS;
|
|
69
|
+
return withNextGenFallback(() => client.agileGet(`/epic/${encodeURIComponent(epicIdOrKey)}/issue`, {
|
|
70
|
+
fields: issueFields,
|
|
71
|
+
maxResults,
|
|
72
|
+
startAt,
|
|
73
|
+
}), () => searchIssues(client, {
|
|
74
|
+
// JQL `parent` accepts a key or a numeric issue ID.
|
|
75
|
+
jql: `parent = ${epicIdOrKey} ORDER BY rank`,
|
|
76
|
+
fields: issueFields,
|
|
77
|
+
maxResults,
|
|
78
|
+
startAt,
|
|
79
|
+
nextPageToken,
|
|
80
|
+
}));
|
|
81
|
+
}));
|
|
30
82
|
server.registerTool("create_epic", {
|
|
31
83
|
title: "Create epic",
|
|
32
|
-
description: "Create
|
|
84
|
+
description: "Create an epic in a project. Company-managed projects usually also require the 'Epic Name' custom field — call get_epic_meta or get_issue_create_meta first and pass it via customFields.",
|
|
33
85
|
inputSchema: {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
name: z.string().describe("Epic name"),
|
|
86
|
+
projectKey: z.string().describe("Project key, e.g. PROJ"),
|
|
87
|
+
name: z.string().describe("Epic name (used as the issue summary)"),
|
|
37
88
|
description: z.string().optional(),
|
|
38
|
-
|
|
89
|
+
issueType: z
|
|
90
|
+
.string()
|
|
91
|
+
.optional()
|
|
92
|
+
.describe("Epic issue type name or ID (default: Epic); see get_epic_meta"),
|
|
93
|
+
assignee: z.string().optional().describe("Account ID on Cloud/v3, username on DC/v2"),
|
|
94
|
+
customFields: z
|
|
95
|
+
.record(z.string(), z.unknown())
|
|
96
|
+
.optional()
|
|
97
|
+
.describe("Extra fields, e.g. { 'customfield_10011': 'Epic name' } for company-managed projects"),
|
|
39
98
|
},
|
|
40
|
-
}, async ({
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
99
|
+
}, async ({ projectKey, name, description, issueType, assignee, customFields }) => run(() => {
|
|
100
|
+
const type = issueType ?? "Epic";
|
|
101
|
+
return client.apiPost("/issue", {
|
|
102
|
+
fields: {
|
|
103
|
+
project: { key: projectKey },
|
|
104
|
+
summary: name,
|
|
105
|
+
issuetype: /^\d+$/.test(type) ? { id: type } : { name: type },
|
|
106
|
+
description,
|
|
107
|
+
assignee: assignee
|
|
108
|
+
? client.apiVersion === "3"
|
|
109
|
+
? { accountId: assignee }
|
|
110
|
+
: { name: assignee }
|
|
111
|
+
: undefined,
|
|
112
|
+
...customFields,
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
}));
|
|
45
116
|
server.registerTool("move_issue_to_epic", {
|
|
46
117
|
title: "Move issue to epic",
|
|
47
|
-
description: 'Add
|
|
118
|
+
description: 'Add issues to an epic. On team-managed projects this sets the issue\'s parent instead. To remove an issue from an epic, use update_issue with fields { "parent": null } (team-managed) or { "epic": null } (company-managed).',
|
|
48
119
|
inputSchema: {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
epicKey: z.string().describe("Epic key, e.g. PROJ-1"),
|
|
120
|
+
epicIdOrKey: z.string().describe("Epic key, e.g. PROJ-1, or numeric issue ID"),
|
|
121
|
+
issueKeys: z.array(z.string()).min(1).describe("Issue keys to move, e.g. ['PROJ-2', 'PROJ-3']"),
|
|
52
122
|
},
|
|
53
|
-
}, async ({
|
|
123
|
+
}, async ({ epicIdOrKey, issueKeys }) => run(() => withNextGenFallback(() => client.agilePost(`/epic/${encodeURIComponent(epicIdOrKey)}/issue`, { issues: issueKeys }), async () => {
|
|
124
|
+
for (const issueKey of issueKeys) {
|
|
125
|
+
await client.apiPut(`/issue/${encodeURIComponent(issueKey)}`, {
|
|
126
|
+
fields: { parent: { key: epicIdOrKey } },
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return { moved: issueKeys, parent: epicIdOrKey };
|
|
130
|
+
})));
|
|
54
131
|
server.registerTool("get_epic_meta", {
|
|
55
132
|
title: "Get epic meta",
|
|
56
|
-
description: "
|
|
133
|
+
description: "List the epic-level issue types available in a project (hierarchy level above Story/Task), with the fields required to create one.",
|
|
57
134
|
inputSchema: {
|
|
58
|
-
projectKey: z.string().
|
|
135
|
+
projectKey: z.string().describe("Project key, e.g. PROJ"),
|
|
59
136
|
},
|
|
60
|
-
}, async ({ projectKey }) => run(() =>
|
|
137
|
+
}, async ({ projectKey }) => run(async () => {
|
|
138
|
+
const meta = (await client.apiGet(`/issue/createmeta/${encodeURIComponent(projectKey)}/issuetypes`));
|
|
139
|
+
const all = meta.issueTypes ?? [];
|
|
140
|
+
const epicTypes = all.filter((t) => (t.hierarchyLevel ?? 0) > 0 || /epic/i.test(t.name));
|
|
141
|
+
return {
|
|
142
|
+
projectKey,
|
|
143
|
+
epicIssueTypes: epicTypes,
|
|
144
|
+
allIssueTypes: all.map((t) => ({ id: t.id, name: t.name, hierarchyLevel: t.hierarchyLevel })),
|
|
145
|
+
hint: epicTypes.length > 0
|
|
146
|
+
? `Create one with create_epic(projectKey: "${projectKey}", issueType: "${epicTypes[0].name}", name: ...). Use get_issue_create_meta for the full required-field list.`
|
|
147
|
+
: "No epic-level issue type is available in this project.",
|
|
148
|
+
};
|
|
149
|
+
}));
|
|
61
150
|
}
|
package/dist/tools/issues.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { run } from "../util.js";
|
|
3
|
+
import { searchIssues } from "../search.js";
|
|
3
4
|
const DEFAULT_ISSUE_FIELDS = "summary,description,status,assignee,reporter,issuetype,labels,components,priority,created,updated,due,project";
|
|
4
5
|
export function registerIssueTools(server, client) {
|
|
5
6
|
server.registerTool("get_issue", {
|
|
@@ -65,20 +66,33 @@ export function registerIssueTools(server, client) {
|
|
|
65
66
|
}, async ({ issueKey }) => run(() => client.apiDelete(`/issue/${encodeURIComponent(issueKey)}`)));
|
|
66
67
|
server.registerTool("search_issues", {
|
|
67
68
|
title: "Search issues (JQL)",
|
|
68
|
-
description: "Search issues with JQL, e.g. 'project = PROJ AND sprint = 42 ORDER BY rank' or 'project = PROJ AND sprint IS NONE'.
|
|
69
|
+
description: "Search issues with JQL, e.g. 'project = PROJ AND sprint = 42 ORDER BY rank' or 'project = PROJ AND sprint IS NONE'. " +
|
|
70
|
+
"On Jira Cloud this uses enhanced search (/search/jql): the JQL must be bounded (include a restriction such as project, assignee, or key — a bare 'ORDER BY created DESC' is rejected), " +
|
|
71
|
+
"the response has no total, and paging is by cursor — pass the returned nextPageToken back in for the next page and stop when isLast is true. " +
|
|
72
|
+
"On Jira Data Center this uses the legacy /search endpoint, which pages with startAt and returns total.",
|
|
69
73
|
inputSchema: {
|
|
70
|
-
jql: z.string().describe("JQL query string"),
|
|
74
|
+
jql: z.string().describe("JQL query string (must be bounded on Jira Cloud)"),
|
|
71
75
|
fields: z.string().optional().describe(`Comma-separated fields (default: ${DEFAULT_ISSUE_FIELDS})`),
|
|
72
76
|
maxResults: z.number().int().min(1).max(100).default(25),
|
|
73
|
-
startAt: z.number().int().min(0).default(0),
|
|
77
|
+
startAt: z.number().int().min(0).default(0).describe("Offset paging, Jira Data Center only; rejected on Cloud, use nextPageToken"),
|
|
78
|
+
nextPageToken: z
|
|
79
|
+
.string()
|
|
80
|
+
.optional()
|
|
81
|
+
.describe("Jira Cloud only: cursor from the previous response's nextPageToken"),
|
|
74
82
|
expand: z.string().optional().describe("e.g. names, uris"),
|
|
83
|
+
includeApproximateTotal: z
|
|
84
|
+
.boolean()
|
|
85
|
+
.default(false)
|
|
86
|
+
.describe("Jira Cloud only: add an extra request for an approximate match count (approximateTotal)"),
|
|
75
87
|
},
|
|
76
|
-
}, async ({ jql, fields, maxResults, startAt, expand }) => run(() => client
|
|
88
|
+
}, async ({ jql, fields, maxResults, startAt, nextPageToken, expand, includeApproximateTotal }) => run(() => searchIssues(client, {
|
|
77
89
|
jql,
|
|
78
90
|
fields: fields ?? DEFAULT_ISSUE_FIELDS,
|
|
79
91
|
maxResults,
|
|
80
92
|
startAt,
|
|
93
|
+
nextPageToken,
|
|
81
94
|
expand,
|
|
95
|
+
includeApproximateTotal,
|
|
82
96
|
})));
|
|
83
97
|
server.registerTool("get_issue_create_meta", {
|
|
84
98
|
title: "Get issue create metadata",
|
package/dist/tools/projects.js
CHANGED
|
@@ -35,11 +35,18 @@ export function registerProjectTools(server, client) {
|
|
|
35
35
|
}, async ({ projectKey, name, description }) => run(() => client.apiPost(`/project/${encodeURIComponent(projectKey)}/components`, { name, description })));
|
|
36
36
|
server.registerTool("get_project_issue_types", {
|
|
37
37
|
title: "Get project issue types",
|
|
38
|
-
description: "List issue types available in a project.",
|
|
38
|
+
description: "List the issue types available in a project, with their hierarchy levels (epic = 1, story/task = 0, sub-task = -1).",
|
|
39
39
|
inputSchema: {
|
|
40
40
|
projectKey: z.string().describe("Project key"),
|
|
41
|
+
maxResults: z.number().int().min(1).max(1000).default(100),
|
|
42
|
+
startAt: z.number().int().min(0).default(0),
|
|
41
43
|
},
|
|
42
|
-
},
|
|
44
|
+
},
|
|
45
|
+
// /project/{key}/issuetypes does not exist; createmeta is the supported source.
|
|
46
|
+
async ({ projectKey, maxResults, startAt }) => run(() => client.apiGet(`/issue/createmeta/${encodeURIComponent(projectKey)}/issuetypes`, {
|
|
47
|
+
maxResults,
|
|
48
|
+
startAt,
|
|
49
|
+
})));
|
|
43
50
|
server.registerTool("get_project_roles", {
|
|
44
51
|
title: "Get project roles",
|
|
45
52
|
description: "List roles (and their actors) of a project.",
|
package/dist/tools/sprints.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
2
|
+
import { run } from "../util.js";
|
|
3
3
|
const sprintState = z.enum(["active", "closed", "future"]).describe("Sprint state filter");
|
|
4
4
|
export function registerSprintTools(server, client) {
|
|
5
5
|
server.registerTool("list_sprints", {
|
|
@@ -68,7 +68,7 @@ export function registerSprintTools(server, client) {
|
|
|
68
68
|
})));
|
|
69
69
|
server.registerTool("get_sprint_view", {
|
|
70
70
|
title: "Get sprint view",
|
|
71
|
-
description: "Full sprint view like the Jira UI: board
|
|
71
|
+
description: "Full sprint view like the Jira UI: board details, the sprint, and its issues, in one call.",
|
|
72
72
|
inputSchema: {
|
|
73
73
|
boardId: z.number().int().describe("Board ID"),
|
|
74
74
|
sprintId: z.number().int().describe("Sprint ID"),
|
|
@@ -76,15 +76,30 @@ export function registerSprintTools(server, client) {
|
|
|
76
76
|
.string()
|
|
77
77
|
.optional()
|
|
78
78
|
.describe("Comma-separated issue fields to return (default: key, summary, status, assignee)"),
|
|
79
|
+
maxResults: z.number().int().min(1).max(1000).default(100),
|
|
80
|
+
startAt: z.number().int().min(0).default(0),
|
|
79
81
|
},
|
|
80
|
-
}, async ({ boardId, sprintId, fields }) => run(() =>
|
|
81
|
-
|
|
82
|
-
|
|
82
|
+
}, async ({ boardId, sprintId, fields, maxResults, startAt }) => run(async () => {
|
|
83
|
+
// Composed from three documented Agile endpoints; the old /rest/agile/1.0/rapid
|
|
84
|
+
// path this used to call is not a real endpoint and answered 404.
|
|
85
|
+
const [board, sprint, issues] = await Promise.all([
|
|
86
|
+
client.agileGet(`/board/${boardId}`),
|
|
87
|
+
client.agileGet(`/sprint/${sprintId}`),
|
|
88
|
+
client.agileGet(`/sprint/${sprintId}/issue`, {
|
|
89
|
+
fields: fields ?? "key,summary,status,assignee",
|
|
90
|
+
maxResults,
|
|
91
|
+
startAt,
|
|
92
|
+
}),
|
|
93
|
+
]);
|
|
94
|
+
return { board, sprint, issues };
|
|
95
|
+
}));
|
|
83
96
|
server.registerTool("get_backlog", {
|
|
84
97
|
title: "Get board backlog",
|
|
85
|
-
description: "List
|
|
98
|
+
description: "List a board's backlog issues (in the board's filter, not in a sprint), ordered by rank. " +
|
|
99
|
+
"Uses the Agile API's own backlog endpoint, so it pages with startAt/total on both Cloud and Data Center.",
|
|
86
100
|
inputSchema: {
|
|
87
101
|
boardId: z.number().int().describe("Board ID"),
|
|
102
|
+
jql: z.string().optional().describe("Extra JQL to narrow the backlog, e.g. 'assignee IS EMPTY'"),
|
|
88
103
|
fields: z
|
|
89
104
|
.string()
|
|
90
105
|
.optional()
|
|
@@ -92,15 +107,10 @@ export function registerSprintTools(server, client) {
|
|
|
92
107
|
maxResults: z.number().int().min(1).max(100).default(50),
|
|
93
108
|
startAt: z.number().int().min(0).default(0),
|
|
94
109
|
},
|
|
95
|
-
}, async ({ boardId, fields, maxResults, startAt }) => run(
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
fields: fields ?? "key,summary,status,assignee",
|
|
102
|
-
maxResults,
|
|
103
|
-
startAt,
|
|
104
|
-
});
|
|
105
|
-
}));
|
|
110
|
+
}, async ({ boardId, jql, fields, maxResults, startAt }) => run(() => client.agileGet(`/board/${boardId}/backlog`, {
|
|
111
|
+
jql,
|
|
112
|
+
fields: fields ?? "key,summary,status,assignee",
|
|
113
|
+
maxResults,
|
|
114
|
+
startAt,
|
|
115
|
+
})));
|
|
106
116
|
}
|
package/dist/util.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thammarongg/jira-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "MCP server exposing the Jira REST API (boards, sprints, issues, JQL, and a generic passthrough) for Jira Cloud and Data Center",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"claude"
|
|
26
26
|
],
|
|
27
27
|
"scripts": {
|
|
28
|
-
"build": "tsc",
|
|
28
|
+
"build": "tsc && node -e \"require('fs').chmodSync('dist/index.js', 0o755)\"",
|
|
29
29
|
"typecheck": "tsc --noEmit",
|
|
30
30
|
"start": "node dist/index.js",
|
|
31
31
|
"dev": "tsx src/index.ts",
|
package/skill/SKILL.md
CHANGED
|
@@ -13,8 +13,18 @@ server-side.
|
|
|
13
13
|
|
|
14
14
|
## If the jira MCP tools are not available
|
|
15
15
|
|
|
16
|
-
Tell the user the server isn't configured and offer to set it up.
|
|
17
|
-
|
|
16
|
+
Tell the user the server isn't configured and offer to set it up. Easiest is
|
|
17
|
+
the interactive installer (writes config for the selected agents — Claude
|
|
18
|
+
Code, OpenCode, Codex, Cursor, Claude Desktop, Gemini CLI; backs up existing
|
|
19
|
+
files to `.bak`):
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npx -y @thammarongg/jira-mcp install
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Or non-interactive: `npx -y @thammarongg/jira-mcp install --agents all
|
|
26
|
+
--base-url https://your-org.atlassian.net --email you@example.com --token xxx
|
|
27
|
+
--yes`. Required env vars:
|
|
18
28
|
|
|
19
29
|
- `JIRA_BASE_URL` — `https://your-org.atlassian.net` (Cloud) or
|
|
20
30
|
`https://jira.yourcompany.com` (DC)
|
|
@@ -57,8 +67,9 @@ After setup, verify with the `get_current_user` tool.
|
|
|
57
67
|
- **Boards/sprints**: `list_boards`, `get_board`, `list_sprints`, `get_sprint`,
|
|
58
68
|
`create_sprint`, `update_sprint`, `close_sprint`, `get_sprint_issues`,
|
|
59
69
|
`get_sprint_view` (UI-like full view), `get_backlog`
|
|
60
|
-
- **Epics**: `list_epics
|
|
61
|
-
`move_issue_to_epic`, `get_epic_meta`
|
|
70
|
+
- **Epics**: `list_epics` (by board), `get_epic`, `get_epic_issues`,
|
|
71
|
+
`create_epic`, `move_issue_to_epic`, `get_epic_meta` — epics are addressed by
|
|
72
|
+
key or numeric ID, and these work on team-managed projects too
|
|
62
73
|
- **Issues**: `get_issue`, `create_issue`, `update_issue`, `delete_issue`,
|
|
63
74
|
`search_issues` (JQL), `get_issue_create_meta`, `get_issue_transitions`,
|
|
64
75
|
`transition_issue`, `assign_issue`, `add_comment`, `list_comments`,
|
|
@@ -74,8 +85,13 @@ After setup, verify with the `get_current_user` tool.
|
|
|
74
85
|
**Sprint status**: `list_boards` → pick board → `list_sprints` (state
|
|
75
86
|
`active`) → `get_sprint_issues` or `get_sprint_view` for the full picture.
|
|
76
87
|
|
|
77
|
-
**Backlog review**: `get_backlog(boardId)` — issues
|
|
78
|
-
|
|
88
|
+
**Backlog review**: `get_backlog(boardId)` — issues on the board that are not
|
|
89
|
+
in a sprint, ordered by rank; pass `jql` to narrow it.
|
|
90
|
+
|
|
91
|
+
**Epic breakdown**: `list_epics(boardId)` → `get_epic_issues(epicIdOrKey)` for
|
|
92
|
+
the children. To create one, `get_epic_meta(projectKey)` gives the epic issue
|
|
93
|
+
type and then `create_epic`; company-managed projects also need the "Epic Name"
|
|
94
|
+
custom field via `customFields`.
|
|
79
95
|
|
|
80
96
|
**Create an issue**: call `get_issue_create_meta(projectKeys=...)` first to
|
|
81
97
|
discover valid issue types and required fields, then `create_issue`
|
|
@@ -84,7 +100,9 @@ discover valid issue types and required fields, then `create_issue`
|
|
|
84
100
|
**Move work through the workflow**: `get_issue_transitions(issueKey)` to see
|
|
85
101
|
available transitions and required fields, then `transition_issue`.
|
|
86
102
|
|
|
87
|
-
**Search**: `search_issues` with JQL.
|
|
103
|
+
**Search**: `search_issues` with JQL. On Jira Cloud the query must be
|
|
104
|
+
**bounded** — always include a restriction such as `project`, `assignee`, or
|
|
105
|
+
`key`; a bare `ORDER BY created DESC` is rejected. Common queries:
|
|
88
106
|
- Current sprint: `project = PROJ AND sprint = <sprintId> ORDER BY rank`
|
|
89
107
|
- Unassigned in project: `project = PROJ AND assignee IS EMPTY`
|
|
90
108
|
- Due this week: `project = PROJ AND duedate <= endOfWeek() ORDER BY duedate`
|
|
@@ -94,6 +112,12 @@ available transitions and required fields, then `transition_issue`.
|
|
|
94
112
|
|
|
95
113
|
- List tools paginate with `startAt`/`maxResults`; responses include `total` —
|
|
96
114
|
page with `startAt` when `total` exceeds the page size.
|
|
115
|
+
- `search_issues` is the exception on **Jira Cloud**: it uses Atlassian's
|
|
116
|
+
enhanced search (`/search/jql`, which replaced the removed `/search`). There
|
|
117
|
+
is no `total`; page by passing the response's `nextPageToken` back in and
|
|
118
|
+
stop when `isLast` is true — `startAt` is rejected. Pass
|
|
119
|
+
`includeApproximateTotal: true` for a rough match count (one extra request).
|
|
120
|
+
On Data Center it still uses `startAt`/`total`.
|
|
97
121
|
- Assignees: pass an account ID on Cloud, a username on DC — the server maps
|
|
98
122
|
it to the right field shape automatically.
|
|
99
123
|
- To remove an issue from an epic: `update_issue` with
|