@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 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 (rapid view: board + sprint + issues) |
144
- | `get_backlog` | Board backlog via JQL (`sprint IS NONE ORDER BY rank`) |
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` / `get_epic` / `get_epic_issues` | Read epics |
151
- | `create_epic` | New epic on a board |
152
- | `move_issue_to_epic` | Add an issue to an epic |
153
- | `get_epic_meta` | Epic issue-type metadata |
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** with pagination |
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
- - `get_backlog` is implemented via JQL since the Agile API has no direct backlog endpoint.
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.1.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
- console.error(`jira-mcp failed to start: ${err instanceof Error ? err.message : String(err)}`);
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
- const PKG = "@thammarongg/jira-mcp";
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
- const selected = flags.agents ? resolveAgents(flags.agents) : await promptSelection();
72
- const env = flags.baseUrl && (flags.email || flags.username)
73
- ? buildEnvFromFlags(flags)
74
- : await promptCredentials();
75
- console.log("\nWill configure the jira MCP server in:");
76
- for (const agent of selected)
77
- console.log(` - ${agent.label.padEnd(15)} ${agent.path}`);
78
- console.log(`Env vars: ${Object.keys(env).join(", ")} (values not shown)`);
79
- if (!flags.yes) {
80
- const rl = makeRl();
81
- const answer = (await ask(rl, "\nProceed? [Y/n] ")).trim().toLowerCase();
82
- rl.close();
83
- if (answer !== "" && answer !== "y" && answer !== "yes") {
84
- console.log("Aborted.");
85
- return;
86
- }
87
- }
88
- let failed = false;
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
- catch (err) {
95
- failed = true;
96
- console.error(` ERR ${agent.label.padEnd(15)} ${agent.path}: ${err instanceof Error ? err.message : String(err)}`);
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 makeRl() {
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(rl, "\nChoice (e.g. 1,3 or a): ")).trim().toLowerCase();
190
- rl.close();
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 rl = makeRl();
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(rl, "Deployment: [1] Jira Cloud [2] Jira Data Center (default 1): ")).trim();
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(rl, isCloud ? "Atlassian email: " : "Data Center username: ")).trim();
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
- const parsed = raw.trim() ? JSON.parse(raw) : {};
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
- copyFileSync(path, `${path}.bak`);
260
- console.log(` ! ${path} was not valid JSON — backed up to ${path}.bak, starting fresh`);
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
- writeFileSync(path, JSON.stringify(obj, null, 2) + "\n");
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
- return `"${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
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
- writeFileSync(path, block.join("\n") + "\n");
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 lines = raw.split("\n");
287
- const start = lines.findIndex((l) => /^\s*\[mcp_servers\.jira\]\s*$/.test(l));
288
- if (start >= 0) {
289
- let end = lines.length;
290
- for (let i = start + 1; i < lines.length; i++) {
291
- if (/^\s*\[/.test(lines[i])) {
292
- end = i;
293
- break;
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.splice(start, end - start, ...block);
297
- writeFileSync(path, lines.join("\n"));
298
- return "updated";
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
- writeFileSync(path, raw.replace(/\s*$/, "") + "\n\n" + block.join("\n") + "\n");
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
+ }
@@ -1,61 +1,150 @@
1
1
  import { z } from "zod";
2
- import { rapidViewId, run } from "../util.js";
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 visible in a board's sprint view (agile rapid view).",
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
- sprintId: z.number().int().describe("Sprint ID (any sprint on the board works)"),
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, sprintId, maxResults, startAt }) => run(() => client.agileGet(`/rapid/${rapidViewId(boardId, sprintId)}/epic`, { maxResults, startAt })));
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 its issue ID (numeric).",
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
- epicId: z.number().int().describe("Epic issue ID (numeric, not the key)"),
45
+ epicIdOrKey: z.string().describe("Epic key, e.g. PROJ-1, or numeric issue ID"),
19
46
  },
20
- }, async ({ epicId }) => run(() => client.agileGet(`/epic/${epicId}`)));
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
- epicId: z.number().int().describe("Epic issue ID (numeric)"),
26
- maxResults: z.number().int().min(1).max(1000).default(100),
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 ({ epicId, maxResults, startAt }) => run(() => client.agileGet(`/epic/${epicId}/issue`, { maxResults, startAt })));
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 a new epic on a board (agile rapid view).",
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
- boardId: z.number().int().describe("Board ID"),
35
- sprintId: z.number().int().describe("Sprint ID (any sprint on the board works)"),
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
- lead: z.string().optional().describe("Epic lead (user account ID or username)"),
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 ({ boardId, sprintId, name, description, lead }) => run(() => client.agilePost(`/rapid/${rapidViewId(boardId, sprintId)}/epic`, {
41
- name,
42
- description,
43
- lead,
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 an issue to an epic. To remove an issue from an epic, use `update_issue` with fields `{ "epic": null }`.',
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
- epicId: z.number().int().describe("Epic issue ID (numeric)"),
50
- issueId: z.number().int().describe("Issue ID to move (numeric)"),
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 ({ epicId, issueId, epicKey }) => run(() => client.agilePut(`/epic/${epicId}/issue/${issueId}`, { epic: { key: epicKey } })));
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: "Get epic metadata (available epic issue types) for a project.",
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().optional().describe("Project key filter"),
135
+ projectKey: z.string().describe("Project key, e.g. PROJ"),
59
136
  },
60
- }, async ({ projectKey }) => run(() => client.agileGet("/epic/meta", { projectKey })));
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
  }
@@ -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'. Returns issues plus total count and pagination info.",
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.apiGet("/search", {
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",
@@ -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
- }, async ({ projectKey }) => run(() => client.apiGet(`/project/${encodeURIComponent(projectKey)}/issuetypes`)));
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.",
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { rapidViewId, run } from "../util.js";
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 info, current sprint, and all its issues. Uses the agile rapid view API (rapidViewId = boardId * 10^13 + sprintId).",
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(() => client.agileGet(`/rapid/${rapidViewId(boardId, sprintId)}`, {
81
- fields: fields ?? "key,summary,status,assignee",
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 backlog issues for a board (issues in the board's projects with no sprint), ordered by rank. Implemented via JQL: project in (<board projects>) AND sprint IS NONE ORDER BY rank.",
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(async () => {
96
- const board = (await client.agileGet(`/board/${boardId}`));
97
- const keys = (board.projects ?? []).map((p) => p.key);
98
- const jql = keys.length > 0 ? `project in (${keys.join(", ")}) AND sprint IS NONE ORDER BY rank` : "sprint IS NONE ORDER BY rank";
99
- return client.apiGet("/search", {
100
- jql,
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
@@ -19,6 +19,3 @@ export async function run(fn) {
19
19
  };
20
20
  }
21
21
  }
22
- export function rapidViewId(boardId, sprintId) {
23
- return boardId * 10_000_000_000_000 + sprintId;
24
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thammarongg/jira-mcp",
3
- "version": "0.1.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. Required
17
- env vars:
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`, `get_epic`, `get_epic_issues`, `create_epic`,
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 in the board's projects
78
- with no sprint, ordered by rank.
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. Common queries:
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