@thammarongg/jira-mcp 0.2.0 → 0.4.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
@@ -101,7 +101,7 @@ npm run build
101
101
 
102
102
  | Variable | Required | Description |
103
103
  | --- | --- | --- |
104
- | `JIRA_BASE_URL` | yes | `https://your-org.atlassian.net` (Cloud) or `https://jira.yourcompany.com` (DC) |
104
+ | `JIRA_BASE_URL` | yes | `https://your-org.atlassian.net` (Cloud) or `https://jira.yourcompany.com` (DC); a DC context path such as `/jira` is preserved |
105
105
  | `JIRA_EMAIL` | Cloud | Your Atlassian account email |
106
106
  | `JIRA_API_TOKEN` | yes | API token (Cloud: id.atlassian.com → Security → API tokens; DC: personal access token) |
107
107
  | `JIRA_USERNAME` | DC | Username (alternative to `JIRA_EMAIL`) |
@@ -160,29 +160,30 @@ mkdir -p ~/.agents/skills/jira && cp skill/SKILL.md ~/.agents/skills/jira/
160
160
 
161
161
  ## Tools
162
162
 
163
- ### Boards & sprints (Agile API)
163
+ ### Boards & sprints (Agile + Software APIs)
164
164
 
165
165
  | Tool | Description |
166
166
  | --- | --- |
167
- | `list_boards` | List boards (filter by type/name/project) |
167
+ | `list_boards` | List boards (filter by type/name/project key or ID) |
168
168
  | `get_board` | Board details incl. projects |
169
169
  | `list_sprints` | Sprints on a board (state: active/closed/future) |
170
170
  | `get_sprint` | Sprint details |
171
171
  | `create_sprint` | New sprint on a board |
172
172
  | `update_sprint` | Rename, reschedule, change goal/state |
173
173
  | `close_sprint` | Close a sprint |
174
- | `get_sprint_issues` | Issues in a sprint |
175
- | `get_sprint_view` | Full UI-like sprint view (rapid view: board + sprint + issues) |
176
- | `get_backlog` | Board backlog via JQL (`sprint IS NONE ORDER BY rank`) |
174
+ | `get_sprint_issues` | Issues in a sprint (Cloud cursor or DC offset paging) |
175
+ | `get_sprint_view` | Full UI-like sprint view (board + sprint + issues in one call) |
176
+ | `get_backlog` | Board backlog (Cloud Software enhanced or DC Agile, rank-ordered) |
177
177
 
178
178
  ### Epics
179
179
 
180
180
  | Tool | Description |
181
181
  | --- | --- |
182
- | `list_epics` / `get_epic` / `get_epic_issues` | Read epics |
183
- | `create_epic` | New epic on a board |
184
- | `move_issue_to_epic` | Add an issue to an epic |
185
- | `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 |
186
187
 
187
188
  ### Issues
188
189
 
@@ -192,11 +193,11 @@ mkdir -p ~/.agents/skills/jira && cp skill/SKILL.md ~/.agents/skills/jira/
192
193
  | `create_issue` | Create (supports custom fields) |
193
194
  | `update_issue` | Set fields and/or relative `update` ops |
194
195
  | `delete_issue` | Delete |
195
- | `search_issues` | **JQL search** with pagination |
196
- | `get_issue_create_meta` | Discover projects/types/required fields |
196
+ | `search_issues` | **JQL search** enhanced search (`/search/jql`) on Cloud, legacy `/search` on Data Center |
197
+ | `get_issue_create_meta` | Discover projects/types/required fields (current per-project metadata endpoints) |
197
198
  | `get_issue_transitions` / `transition_issue` | Workflow transitions |
198
199
  | `assign_issue` | Assign/unassign |
199
- | `add_comment` / `list_comments` / `delete_comment` | Comments |
200
+ | `add_comment` / `list_comments` / `delete_comment` | Comments (plain text in, auto-converted to ADF on Cloud/v3) |
200
201
  | `get_issue_worklogs` / `add_worklog` | Time tracking |
201
202
 
202
203
  ### Projects, users, meta
@@ -218,6 +219,17 @@ mkdir -p ~/.agents/skills/jira && cp skill/SKILL.md ~/.agents/skills/jira/
218
219
  5. `create_issue` / `transition_issue` / `add_comment` to act
219
220
  6. Anything else → `jira_api`
220
221
 
222
+ ## Rich text and ADF
223
+
224
+ Jira Cloud REST v3 stores descriptions, comment bodies and worklog comments as
225
+ [Atlassian Document Format](https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/),
226
+ not plain strings. Pass plain text to `create_issue`, `create_epic`,
227
+ `update_issue`, `transition_issue`, `add_comment` and `add_worklog` — the server
228
+ wraps it in ADF when `JIRA_API_VERSION` is `3`, and leaves it alone on v2 (Data
229
+ Center), where wiki markup applies. Blank lines start a new paragraph and single
230
+ newlines become line breaks; **Markdown is not rendered** (`**bold**` shows
231
+ literally). Already-built ADF objects are passed through untouched.
232
+
221
233
  ## Development
222
234
 
223
235
  ```bash
@@ -230,8 +242,17 @@ node scripts/smoke.mjs # stdio handshake + tools/list smoke test
230
242
  ## Notes & limitations
231
243
 
232
244
  - Auth is HTTP Basic (email+token for Cloud, username+token/password for DC) — the standard for Jira REST.
233
- - Pagination: list tools return Jira's native `startAt`/`maxResults`/`total`; pass `startAt` to page.
234
- - `get_backlog` is implemented via JQL since the Agile API has no direct backlog endpoint.
245
+ - `JIRA_BASE_URL` may include a Data Center context path (for example `https://jira.example.com/jira`); it is retained when resolving `/rest/...` endpoints.
246
+ - Pagination: most list tools return Jira's native `startAt`/`maxResults`/`total`; pass `startAt` to page.
247
+ - Platform routing: Cloud-only enhanced Software reads use `/rest/software/1.0` for backlog, sprint issues, and epic issues (cursor `nextPageToken`); Data Center uses the corresponding Agile 1.0 endpoints with `startAt`/`total`. `list_boards` passes its project filter as the Agile API's singular `projectKeyOrId` parameter.
248
+ - `create_sprint` uses the documented top-level Agile `POST /rest/agile/1.0/sprint` route and sends the selected board as `originBoardId`.
249
+ - 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`.
250
+ - 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`.
251
+ - `get_issue_create_meta` composes the current per-project and per-issue-type metadata endpoints (`/issue/createmeta/{project}/issuetypes` and `/issue/createmeta/{project}/issuetypes/{issueType}`), because the old `/issue/createmeta` query form is deprecated on Cloud and removed from Jira Data Center 9.0+. Its output keeps the familiar `projects[].issuetypes[].fields` shape. Pass `projectKeys` when fields are expanded (the default); omitting it is allowed only with `expand: "projects.issuetypes"` for bounded issue-type summaries. Project metadata is fetched with a fixed concurrency of four, and issue-type field metadata is likewise limited to four concurrent requests.
252
+ - `get_backlog`, `get_sprint_issues`, and `get_epic_issues` use Cloud's current Software enhanced endpoints with cursor paging; pass `nextPageToken` for the next page. Data Center keeps Agile 1.0 and `startAt`/`total`. The older Cloud Agile issue-list endpoints remain available but are deprecated.
235
253
  - Rapid view IDs are computed as `boardId * 10^13 + sprintId` (Jira's documented convention).
236
254
  - Comment bodies use the `body` field on both Cloud (v3) and Data Center (v2).
255
+ - User references use `accountId` on API v3/Cloud and `name` on API v2/Data Center. `get_user` calls `/user?accountId=...` on v3; the v3 path form `/user/{accountId}` is not a current endpoint. `create_issue`'s `dueDate` input is sent as Jira's `duedate` field; `add_worklog` accepts an optional `started` timestamp and Jira defaults it to now when omitted.
256
+ - 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`.
257
+ - Jira Data Center exposes platform REST API v2/latest, not `/rest/api/3`. Leave `JIRA_API_VERSION` at its default (`2`) for DC; forcing `3` against a DC host is unsupported and causes the server to request unavailable `/rest/api/3/...` routes. Cloud-vs-DC endpoint routing is based on the detected deployment (`*.atlassian.net` is Cloud), while field representations are based on the selected API version.
237
258
  - `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/adf.js ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Jira Cloud REST v3 takes rich-text fields (descriptions, comment bodies,
3
+ * worklog comments) as Atlassian Document Format, not plain strings. v2 (Data
4
+ * Center) still takes wiki-markup strings, so every conversion is gated on the
5
+ * API version the client is talking to.
6
+ */
7
+ /** Wrap plain text in a minimal ADF document. Markdown is not interpreted. */
8
+ export function toAdfDoc(text) {
9
+ const content = text
10
+ .replace(/\r\n/g, "\n")
11
+ .split(/\n{2,}/)
12
+ .map((block) => block.split("\n").filter((line) => line !== ""))
13
+ .filter((lines) => lines.length > 0)
14
+ .map((lines) => ({
15
+ type: "paragraph",
16
+ content: lines.flatMap((line, i) => i === 0 ? [{ type: "text", text: line }] : [{ type: "hardBreak" }, { type: "text", text: line }]),
17
+ }));
18
+ return { type: "doc", version: 1, content };
19
+ }
20
+ /**
21
+ * Convert a rich-text value for the given API version. Strings become ADF on
22
+ * v3; anything else (a caller-built ADF doc, undefined) passes through.
23
+ */
24
+ export function richText(value, apiVersion) {
25
+ if (apiVersion !== "3" || typeof value !== "string")
26
+ return value;
27
+ return toAdfDoc(value);
28
+ }
29
+ const RICH_TEXT_FIELDS = ["description", "environment"];
30
+ /** Convert the known rich-text keys of an issue `fields` payload in place-safe fashion. */
31
+ export function richTextFields(fields, apiVersion) {
32
+ if (!fields || apiVersion !== "3")
33
+ return fields;
34
+ let out;
35
+ for (const key of RICH_TEXT_FIELDS) {
36
+ if (typeof fields[key] === "string") {
37
+ out ??= { ...fields };
38
+ out[key] = toAdfDoc(fields[key]);
39
+ }
40
+ }
41
+ return out ?? fields;
42
+ }
43
+ /**
44
+ * Convert comment bodies inside an issue `update` payload, e.g.
45
+ * { comment: [{ add: { body: "text" } }] }.
46
+ */
47
+ export function richTextUpdate(update, apiVersion) {
48
+ if (!update || apiVersion !== "3")
49
+ return update;
50
+ const ops = update.comment;
51
+ if (!Array.isArray(ops))
52
+ return update;
53
+ let changed = false;
54
+ const comment = ops.map((op) => {
55
+ if (!op || typeof op !== "object")
56
+ return op;
57
+ const entry = op;
58
+ let next;
59
+ for (const verb of ["add", "edit"]) {
60
+ const payload = entry[verb];
61
+ if (payload && typeof payload === "object" && typeof payload.body === "string") {
62
+ next ??= { ...entry };
63
+ const inner = payload;
64
+ next[verb] = { ...inner, body: toAdfDoc(inner.body) };
65
+ changed = true;
66
+ }
67
+ }
68
+ return next ?? entry;
69
+ });
70
+ return changed ? { ...update, comment } : update;
71
+ }
package/dist/client.js CHANGED
@@ -21,7 +21,9 @@ export class JiraClient {
21
21
  }
22
22
  async request(method, path, opts = {}) {
23
23
  const url = new URL(`${this.cfg.baseUrl}${path.startsWith("/") ? path : `/${path}`}`);
24
- if (!url.pathname.startsWith("/rest/")) {
24
+ const basePath = new URL(this.cfg.baseUrl).pathname.replace(/\/+$/, "");
25
+ const restPrefix = `${basePath}/rest/`;
26
+ if (!url.pathname.startsWith(restPrefix)) {
25
27
  throw new Error(`Jira API path must start with /rest/ (resolved to ${url.pathname})`);
26
28
  }
27
29
  if (opts.query) {
@@ -81,6 +83,9 @@ export class JiraClient {
81
83
  agilePut(path, body) {
82
84
  return this.request("PUT", `/rest/agile/1.0${path}`, { body });
83
85
  }
86
+ softwareGet(path, query) {
87
+ return this.request("GET", `/rest/software/1.0${path}`, { query });
88
+ }
84
89
  }
85
90
  function extractErrorMessage(body) {
86
91
  if (typeof body === "string" && body.trim()) {
package/dist/config.js CHANGED
@@ -8,7 +8,7 @@ export function loadConfig(env = process.env) {
8
8
  const url = new URL(rawBase);
9
9
  if (!/^https?:$/.test(url.protocol))
10
10
  throw new Error("bad protocol");
11
- baseUrl = `${url.protocol}//${url.host}`;
11
+ baseUrl = `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, "")}`;
12
12
  }
13
13
  catch {
14
14
  throw new Error(`JIRA_BASE_URL is not a valid URL: ${rawBase}`);
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.4.0" });
23
23
  registerBoardTools(server, client);
24
24
  registerSprintTools(server, client);
25
25
  registerEpicTools(server, client);
package/dist/install.js CHANGED
@@ -223,10 +223,16 @@ function askPassword(p, promptText) {
223
223
  finish("");
224
224
  return;
225
225
  }
226
- if (ch === "\u007f" || ch === "\b")
227
- value = value.slice(0, -1);
228
- else if (ch >= " ")
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 >= " ") {
229
233
  value += ch;
234
+ process.stdout.write("*");
235
+ }
230
236
  }
231
237
  };
232
238
  const onEnd = () => finish(null);
@@ -344,7 +350,7 @@ async function promptCredentials(p) {
344
350
  const user = (await p.ask(isCloud ? "Atlassian email: " : "Data Center username: ")).trim();
345
351
  if (!user)
346
352
  throw new Error(isCloud ? "Email is required" : "Username is required");
347
- const secret = await askPassword(p, isCloud ? "API token (hidden, from id.atlassian.com): " : "API token / app password (hidden): ");
353
+ const secret = await askPassword(p, isCloud ? "API token (masked, from id.atlassian.com): " : "API token / app password (masked): ");
348
354
  if (!secret)
349
355
  throw new Error("API token is required");
350
356
  const env = { JIRA_BASE_URL: baseUrl };
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
+ }
@@ -3,15 +3,15 @@ import { run } from "../util.js";
3
3
  export function registerBoardTools(server, client) {
4
4
  server.registerTool("list_boards", {
5
5
  title: "List Jira boards",
6
- description: "List Jira agile boards (scrum or kanban), optionally filtered by type, name, or project keys.",
6
+ description: "List Jira agile boards (scrum or kanban), optionally filtered by type, name, or one project key/ID.",
7
7
  inputSchema: {
8
8
  type: z.enum(["scrum", "kanban"]).optional().describe("Board type filter"),
9
9
  name: z.string().optional().describe("Filter by board name (partial match)"),
10
- projectKeys: z.string().optional().describe("Comma-separated project keys, e.g. 'PROJ1,PROJ2'"),
10
+ projectKeys: z.string().optional().describe("Project key or ID (the Agile API accepts one projectKeyOrId value)"),
11
11
  maxResults: z.number().int().min(1).max(1000).default(50),
12
12
  startAt: z.number().int().min(0).default(0),
13
13
  },
14
- }, async ({ type, name, projectKeys, maxResults, startAt }) => run(() => client.agileGet("/board", { type, name, projectKeys, maxResults, startAt })));
14
+ }, async ({ type, name, projectKeys, maxResults, startAt }) => run(() => client.agileGet("/board", { type, name, projectKeyOrId: projectKeys, maxResults, startAt })));
15
15
  server.registerTool("get_board", {
16
16
  title: "Get board",
17
17
  description: "Get a single Jira board by ID, including its projects, location, and settings.",
@@ -1,61 +1,168 @@
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
+ import { richText } from "../adf.js";
6
+ const DEFAULT_EPIC_ISSUE_FIELDS = "key,summary,status,assignee";
7
+ /**
8
+ * The Agile epic endpoints (/rest/agile/1.0/epic/...) only understand classic
9
+ * (company-managed) epics: on a team-managed ("next-gen") project they answer
10
+ * HTTP 400 "The request contains a next-gen issue". Cloud issue listing uses
11
+ * the current Software 1.0 enhanced endpoint; Data Center uses Agile 1.0.
12
+ * There, an epic is an ordinary issue and its children are linked by `parent`,
13
+ * so each tool falls back to the plain issue/search APIs when the endpoint
14
+ * rejects the project style.
15
+ */
16
+ function isNextGenRejection(err) {
17
+ return err instanceof JiraApiError && (err.status === 400 || err.status === 404);
18
+ }
19
+ async function withNextGenFallback(agile, fallback) {
20
+ try {
21
+ return await agile();
22
+ }
23
+ catch (err) {
24
+ if (!isNextGenRejection(err))
25
+ throw err;
26
+ return fallback();
27
+ }
28
+ }
3
29
  export function registerEpicTools(server, client) {
4
30
  server.registerTool("list_epics", {
5
31
  title: "List epics",
6
- description: "List epics visible in a board's sprint view (agile rapid view).",
32
+ description: "List the epics on a board, including whether each one is done.",
7
33
  inputSchema: {
8
34
  boardId: z.number().int().describe("Board ID"),
9
- sprintId: z.number().int().describe("Sprint ID (any sprint on the board works)"),
35
+ done: z.boolean().optional().describe("Filter by completion state; omit for all epics"),
10
36
  maxResults: z.number().int().min(1).max(1000).default(100),
11
37
  startAt: z.number().int().min(0).default(0),
12
38
  },
13
- }, async ({ boardId, sprintId, maxResults, startAt }) => run(() => client.agileGet(`/rapid/${rapidViewId(boardId, sprintId)}/epic`, { maxResults, startAt })));
39
+ }, async ({ boardId, done, maxResults, startAt }) => run(() => client.agileGet(`/board/${boardId}/epic`, {
40
+ done: done === undefined ? undefined : String(done),
41
+ maxResults,
42
+ startAt,
43
+ })));
14
44
  server.registerTool("get_epic", {
15
45
  title: "Get epic",
16
- description: "Get a single epic by its issue ID (numeric).",
46
+ 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
47
  inputSchema: {
18
- epicId: z.number().int().describe("Epic issue ID (numeric, not the key)"),
48
+ epicIdOrKey: z.string().describe("Epic key, e.g. PROJ-1, or numeric issue ID"),
19
49
  },
20
- }, async ({ epicId }) => run(() => client.agileGet(`/epic/${epicId}`)));
50
+ }, async ({ epicIdOrKey }) => run(() => {
51
+ const id = encodeURIComponent(epicIdOrKey);
52
+ return withNextGenFallback(() => client.agileGet(`/epic/${id}`), () => client.apiGet(`/issue/${id}`, { fields: "summary,status,assignee,issuetype,project,description" }));
53
+ }));
21
54
  server.registerTool("get_epic_issues", {
22
55
  title: "Get epic issues",
23
- description: "List the issues belonging to an epic.",
56
+ description: "List the issues belonging to an epic. Cloud uses the current Software API enhanced endpoint; Data Center uses Agile 1.0. Falls back to a JQL 'parent = <epic>' search on team-managed projects, where the epic API does not apply.",
24
57
  inputSchema: {
25
- epicId: z.number().int().describe("Epic issue ID (numeric)"),
26
- maxResults: z.number().int().min(1).max(1000).default(100),
58
+ epicIdOrKey: z.string().describe("Epic key, e.g. PROJ-1, or numeric issue ID"),
59
+ fields: z
60
+ .string()
61
+ .optional()
62
+ .describe(`Comma-separated issue fields (default: ${DEFAULT_EPIC_ISSUE_FIELDS})`),
63
+ maxResults: z.number().int().min(1).max(100).default(100),
27
64
  startAt: z.number().int().min(0).default(0),
65
+ nextPageToken: z
66
+ .string()
67
+ .optional()
68
+ .describe("Jira Cloud only: cursor returned by a previous fallback search"),
28
69
  },
29
- }, async ({ epicId, maxResults, startAt }) => run(() => client.agileGet(`/epic/${epicId}/issue`, { maxResults, startAt })));
70
+ }, async ({ epicIdOrKey, fields, maxResults, startAt, nextPageToken }) => run(() => {
71
+ const issueFields = fields ?? DEFAULT_EPIC_ISSUE_FIELDS;
72
+ return withNextGenFallback(() => {
73
+ if (client.isCloud) {
74
+ if (startAt > 0) {
75
+ throw new Error("Jira Cloud Software epic issue paging uses nextPageToken, not startAt.");
76
+ }
77
+ return client.softwareGet(`/epic/${encodeURIComponent(epicIdOrKey)}/issue`, {
78
+ fields: issueFields,
79
+ maxResults,
80
+ nextPageToken,
81
+ });
82
+ }
83
+ return client.agileGet(`/epic/${encodeURIComponent(epicIdOrKey)}/issue`, {
84
+ fields: issueFields,
85
+ maxResults,
86
+ startAt,
87
+ });
88
+ }, () => searchIssues(client, {
89
+ // JQL `parent` accepts a key or a numeric issue ID.
90
+ jql: `parent = ${epicIdOrKey} ORDER BY rank`,
91
+ fields: issueFields,
92
+ maxResults,
93
+ startAt,
94
+ nextPageToken,
95
+ }));
96
+ }));
30
97
  server.registerTool("create_epic", {
31
98
  title: "Create epic",
32
- description: "Create a new epic on a board (agile rapid view).",
99
+ 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
100
  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"),
37
- description: z.string().optional(),
38
- lead: z.string().optional().describe("Epic lead (user account ID or username)"),
101
+ projectKey: z.string().describe("Project key, e.g. PROJ"),
102
+ name: z.string().describe("Epic name (used as the issue summary)"),
103
+ description: z
104
+ .string()
105
+ .optional()
106
+ .describe("Plain text; converted to ADF on Cloud/v3. Markdown is not rendered."),
107
+ issueType: z
108
+ .string()
109
+ .optional()
110
+ .describe("Epic issue type name or ID (default: Epic); see get_epic_meta"),
111
+ assignee: z.string().optional().describe("Account ID on Cloud/v3, username on DC/v2"),
112
+ customFields: z
113
+ .record(z.string(), z.unknown())
114
+ .optional()
115
+ .describe("Extra fields, e.g. { 'customfield_10011': 'Epic name' } for company-managed projects"),
39
116
  },
40
- }, async ({ boardId, sprintId, name, description, lead }) => run(() => client.agilePost(`/rapid/${rapidViewId(boardId, sprintId)}/epic`, {
41
- name,
42
- description,
43
- lead,
44
- })));
117
+ }, async ({ projectKey, name, description, issueType, assignee, customFields }) => run(() => {
118
+ const type = issueType ?? "Epic";
119
+ return client.apiPost("/issue", {
120
+ fields: {
121
+ project: { key: projectKey },
122
+ summary: name,
123
+ issuetype: /^\d+$/.test(type) ? { id: type } : { name: type },
124
+ description: richText(description, client.apiVersion),
125
+ assignee: assignee
126
+ ? client.apiVersion === "3"
127
+ ? { accountId: assignee }
128
+ : { name: assignee }
129
+ : undefined,
130
+ ...customFields,
131
+ },
132
+ });
133
+ }));
45
134
  server.registerTool("move_issue_to_epic", {
46
135
  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 }`.',
136
+ 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
137
  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"),
138
+ epicIdOrKey: z.string().describe("Epic key, e.g. PROJ-1, or numeric issue ID"),
139
+ issueKeys: z.array(z.string()).min(1).describe("Issue keys to move, e.g. ['PROJ-2', 'PROJ-3']"),
52
140
  },
53
- }, async ({ epicId, issueId, epicKey }) => run(() => client.agilePut(`/epic/${epicId}/issue/${issueId}`, { epic: { key: epicKey } })));
141
+ }, async ({ epicIdOrKey, issueKeys }) => run(() => withNextGenFallback(() => client.agilePost(`/epic/${encodeURIComponent(epicIdOrKey)}/issue`, { issues: issueKeys }), async () => {
142
+ for (const issueKey of issueKeys) {
143
+ await client.apiPut(`/issue/${encodeURIComponent(issueKey)}`, {
144
+ fields: { parent: { key: epicIdOrKey } },
145
+ });
146
+ }
147
+ return { moved: issueKeys, parent: epicIdOrKey };
148
+ })));
54
149
  server.registerTool("get_epic_meta", {
55
150
  title: "Get epic meta",
56
- description: "Get epic metadata (available epic issue types) for a project.",
151
+ description: "List the epic-level issue types available in a project (hierarchy level above Story/Task). Use get_issue_create_meta for the fields required to create one.",
57
152
  inputSchema: {
58
- projectKey: z.string().optional().describe("Project key filter"),
153
+ projectKey: z.string().describe("Project key, e.g. PROJ"),
59
154
  },
60
- }, async ({ projectKey }) => run(() => client.agileGet("/epic/meta", { projectKey })));
155
+ }, async ({ projectKey }) => run(async () => {
156
+ const meta = (await client.apiGet(`/issue/createmeta/${encodeURIComponent(projectKey)}/issuetypes`));
157
+ const all = meta.issueTypes ?? [];
158
+ const epicTypes = all.filter((t) => (t.hierarchyLevel ?? 0) > 0 || /epic/i.test(t.name));
159
+ return {
160
+ projectKey,
161
+ epicIssueTypes: epicTypes,
162
+ allIssueTypes: all.map((t) => ({ id: t.id, name: t.name, hierarchyLevel: t.hierarchyLevel })),
163
+ hint: epicTypes.length > 0
164
+ ? `Create one with create_epic(projectKey: "${projectKey}", issueType: "${epicTypes[0].name}", name: ...). Use get_issue_create_meta for the full required-field list.`
165
+ : "No epic-level issue type is available in this project.",
166
+ };
167
+ }));
61
168
  }
@@ -1,6 +1,92 @@
1
1
  import { z } from "zod";
2
2
  import { run } from "../util.js";
3
- const DEFAULT_ISSUE_FIELDS = "summary,description,status,assignee,reporter,issuetype,labels,components,priority,created,updated,due,project";
3
+ import { searchIssues } from "../search.js";
4
+ import { richText, richTextFields, richTextUpdate } from "../adf.js";
5
+ const DEFAULT_ISSUE_FIELDS = "summary,description,status,assignee,reporter,issuetype,labels,components,priority,created,updated,duedate,project";
6
+ async function getIssueCreateMeta(client, projectKeys, issuetypeIds, expand) {
7
+ const projectFilter = new Set(projectKeys
8
+ ?.split(",")
9
+ .map((value) => value.trim())
10
+ .filter(Boolean));
11
+ const typeFilter = new Set(issuetypeIds
12
+ ?.split(",")
13
+ .map((value) => value.trim())
14
+ .filter(Boolean));
15
+ const includeFields = !expand || expand.includes("fields");
16
+ if (includeFields && projectFilter.size === 0) {
17
+ throw new Error("projectKeys is required when expand includes fields (the default); pass one or more project keys to bound metadata requests, or use expand=projects.issuetypes for issue-type summaries.");
18
+ }
19
+ const projects = projectFilter.size > 0
20
+ ? await mapWithConcurrency([...projectFilter], 4, async (key) => (await client.apiGet(`/project/${encodeURIComponent(key)}`)))
21
+ : await listProjectsForCreateMeta(client);
22
+ const result = [];
23
+ for (const project of projects) {
24
+ const projectIdOrKey = project.key ?? project.id;
25
+ if (!projectIdOrKey) {
26
+ result.push({ ...project, issuetypes: [] });
27
+ continue;
28
+ }
29
+ const issueTypePage = await client.apiGet(`/issue/createmeta/${encodeURIComponent(projectIdOrKey)}/issuetypes`, { maxResults: 200, startAt: 0 });
30
+ const issueTypes = issueTypesFromMeta(issueTypePage).filter((issueType) => typeFilter.size === 0 || typeFilter.has(issueType.id));
31
+ const enriched = includeFields
32
+ ? await mapWithConcurrency(issueTypes, 4, async (issueType) => {
33
+ const fieldPage = await client.apiGet(`/issue/createmeta/${encodeURIComponent(projectIdOrKey)}/issuetypes/${encodeURIComponent(issueType.id)}`, { maxResults: 200, startAt: 0 });
34
+ const fields = fieldsFromMeta(fieldPage);
35
+ return { ...issueType, expand: "fields", fields };
36
+ })
37
+ : issueTypes;
38
+ result.push({ ...project, expand: "issuetypes", issuetypes: enriched });
39
+ }
40
+ return { expand: "projects", projects: result };
41
+ }
42
+ async function mapWithConcurrency(values, limit, fn) {
43
+ const results = new Array(values.length);
44
+ let nextIndex = 0;
45
+ async function worker() {
46
+ const index = nextIndex++;
47
+ if (index >= values.length)
48
+ return;
49
+ results[index] = await fn(values[index], index);
50
+ return worker();
51
+ }
52
+ await Promise.all(Array.from({ length: Math.min(limit, values.length) }, () => worker()));
53
+ return results;
54
+ }
55
+ async function listProjectsForCreateMeta(client) {
56
+ if (client.isCloud) {
57
+ const page = (await client.apiGet("/project/search", { maxResults: 100, startAt: 0 }));
58
+ return page.values ?? [];
59
+ }
60
+ return (await client.apiGet("/project", { maxResults: 1000, startAt: 0 }));
61
+ }
62
+ function issueTypesFromMeta(value) {
63
+ if (Array.isArray(value))
64
+ return value;
65
+ if (!value || typeof value !== "object")
66
+ return [];
67
+ const object = value;
68
+ if (Array.isArray(object.issueTypes))
69
+ return object.issueTypes;
70
+ return typeof object.id === "string" && typeof object.name === "string"
71
+ ? [object]
72
+ : [];
73
+ }
74
+ function fieldsFromMeta(value) {
75
+ if (!value || typeof value !== "object")
76
+ return {};
77
+ const object = value;
78
+ if (Array.isArray(object.fields)) {
79
+ return Object.fromEntries(object.fields
80
+ .filter((field) => typeof field.key === "string" || typeof field.fieldId === "string")
81
+ .map((field) => [String(field.key ?? field.fieldId), field]));
82
+ }
83
+ if (object.fields && typeof object.fields === "object" && !Array.isArray(object.fields)) {
84
+ return object.fields;
85
+ }
86
+ if (typeof object.fieldId === "string")
87
+ return { [object.fieldId]: object };
88
+ return {};
89
+ }
4
90
  export function registerIssueTools(server, client) {
5
91
  server.registerTool("get_issue", {
6
92
  title: "Get issue",
@@ -8,7 +94,10 @@ export function registerIssueTools(server, client) {
8
94
  inputSchema: {
9
95
  issueKey: z.string().describe("Issue key, e.g. PROJ-123"),
10
96
  fields: z.string().optional().describe(`Comma-separated fields (default: ${DEFAULT_ISSUE_FIELDS})`),
11
- expand: z.string().optional().describe("Comma-separated expansions, e.g. renderedFields,versions,changelog"),
97
+ expand: z
98
+ .string()
99
+ .optional()
100
+ .describe("Comma-separated expansions, e.g. renderedFields,names,schema,transitions,editmeta,changelog"),
12
101
  },
13
102
  }, async ({ issueKey, fields, expand }) => run(() => client.apiGet(`/issue/${encodeURIComponent(issueKey)}`, { fields: fields ?? DEFAULT_ISSUE_FIELDS, expand })));
14
103
  server.registerTool("create_issue", {
@@ -18,12 +107,15 @@ export function registerIssueTools(server, client) {
18
107
  projectKey: z.string().describe("Project key, e.g. PROJ"),
19
108
  summary: z.string().describe("Issue summary"),
20
109
  issueType: z.string().describe("Issue type name or ID, e.g. Story, Task, Bug, or 10004"),
21
- description: z.string().optional(),
110
+ description: z
111
+ .string()
112
+ .optional()
113
+ .describe("Plain text; converted to ADF on Cloud/v3. Markdown is not rendered."),
22
114
  assignee: z.string().optional().describe("Account ID on Cloud/v3, username on DC/v2"),
23
115
  reporter: z.string().optional().describe("Account ID on Cloud/v3, username on DC/v2"),
24
116
  labels: z.array(z.string()).optional(),
25
117
  components: z.array(z.string()).optional().describe("Component names"),
26
- dueDate: z.string().optional().describe("Due date, e.g. 2026-09-30"),
118
+ dueDate: z.string().optional().describe("Due date, e.g. 2026-09-30 (sent to Jira as duedate)"),
27
119
  priority: z.string().optional().describe("Priority name, e.g. High"),
28
120
  parent: z.string().optional().describe("Parent issue key (for sub-tasks)"),
29
121
  customFields: z.record(z.string(), z.unknown()).optional().describe("Custom field IDs mapped to values"),
@@ -35,12 +127,12 @@ export function registerIssueTools(server, client) {
35
127
  project: { key: projectKey },
36
128
  summary,
37
129
  issuetype: /^\d+$/.test(issueType) ? { id: issueType } : { name: issueType },
38
- description,
130
+ description: richText(description, client.apiVersion),
39
131
  assignee: assignee ? userRef(assignee) : undefined,
40
132
  reporter: reporter ? userRef(reporter) : undefined,
41
133
  labels,
42
134
  components: components?.map((name) => ({ name })),
43
- dueDate,
135
+ duedate: dueDate,
44
136
  priority: priority ? { name: priority } : undefined,
45
137
  parent: parent ? { key: parent } : undefined,
46
138
  ...customFields,
@@ -52,10 +144,16 @@ export function registerIssueTools(server, client) {
52
144
  description: "Update an issue. Pass absolute values in fields (e.g. { summary: 'new' }) and/or relative changes in update (e.g. { labels: [{ add: 'x' }, { remove: 'y' }] }).",
53
145
  inputSchema: {
54
146
  issueKey: z.string().describe("Issue key, e.g. PROJ-123"),
55
- fields: z.record(z.string(), z.unknown()).optional().describe("Field values to set"),
147
+ fields: z
148
+ .record(z.string(), z.unknown())
149
+ .optional()
150
+ .describe("Field values to set. Plain-text description/environment are converted to ADF on Cloud/v3."),
56
151
  update: z.record(z.string(), z.unknown()).optional().describe("Relative updates (add/remove operations)"),
57
152
  },
58
- }, async ({ issueKey, fields, update }) => run(() => client.apiPut(`/issue/${encodeURIComponent(issueKey)}`, { fields, update })));
153
+ }, async ({ issueKey, fields, update }) => run(() => client.apiPut(`/issue/${encodeURIComponent(issueKey)}`, {
154
+ fields: richTextFields(fields, client.apiVersion),
155
+ update: richTextUpdate(update, client.apiVersion),
156
+ })));
59
157
  server.registerTool("delete_issue", {
60
158
  title: "Delete issue",
61
159
  description: "Delete an issue. This is destructive.",
@@ -65,37 +163,49 @@ export function registerIssueTools(server, client) {
65
163
  }, async ({ issueKey }) => run(() => client.apiDelete(`/issue/${encodeURIComponent(issueKey)}`)));
66
164
  server.registerTool("search_issues", {
67
165
  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.",
166
+ description: "Search issues with JQL, e.g. 'project = PROJ AND sprint = 42 ORDER BY rank' or 'project = PROJ AND sprint IS NONE'. " +
167
+ "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), " +
168
+ "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. " +
169
+ "On Jira Data Center this uses the legacy /search endpoint, which pages with startAt and returns total.",
69
170
  inputSchema: {
70
- jql: z.string().describe("JQL query string"),
171
+ jql: z.string().describe("JQL query string (must be bounded on Jira Cloud)"),
71
172
  fields: z.string().optional().describe(`Comma-separated fields (default: ${DEFAULT_ISSUE_FIELDS})`),
72
173
  maxResults: z.number().int().min(1).max(100).default(25),
73
- startAt: z.number().int().min(0).default(0),
174
+ startAt: z.number().int().min(0).default(0).describe("Offset paging, Jira Data Center only; rejected on Cloud, use nextPageToken"),
175
+ nextPageToken: z
176
+ .string()
177
+ .optional()
178
+ .describe("Jira Cloud only: cursor from the previous response's nextPageToken"),
74
179
  expand: z.string().optional().describe("e.g. names, uris"),
180
+ includeApproximateTotal: z
181
+ .boolean()
182
+ .default(false)
183
+ .describe("Jira Cloud only: add an extra request for an approximate match count (approximateTotal)"),
75
184
  },
76
- }, async ({ jql, fields, maxResults, startAt, expand }) => run(() => client.apiGet("/search", {
185
+ }, async ({ jql, fields, maxResults, startAt, nextPageToken, expand, includeApproximateTotal }) => run(() => searchIssues(client, {
77
186
  jql,
78
187
  fields: fields ?? DEFAULT_ISSUE_FIELDS,
79
188
  maxResults,
80
189
  startAt,
190
+ nextPageToken,
81
191
  expand,
192
+ includeApproximateTotal,
82
193
  })));
83
194
  server.registerTool("get_issue_create_meta", {
84
195
  title: "Get issue create metadata",
85
- description: "Discover valid projects, issue types, and their fields (with required flags) before creating an issue.",
196
+ description: "Discover valid projects, issue types, and their fields (with required flags) before creating an issue. Uses the current per-project metadata endpoints instead of the deprecated /issue/createmeta query form. Pass projectKeys when fields are expanded (the default) to bound requests; omit it only with expand=projects.issuetypes for issue-type summaries.",
86
197
  inputSchema: {
87
- projectKeys: z.string().optional().describe("Comma-separated project keys to filter"),
198
+ projectKeys: z
199
+ .string()
200
+ .optional()
201
+ .describe("Comma-separated project keys; required when expand includes fields (the default)"),
88
202
  issuetypeIds: z.string().optional().describe("Comma-separated issue type IDs to filter"),
89
203
  expand: z
90
204
  .string()
91
205
  .optional()
92
206
  .describe("Use 'projects.issuetypes.fields' for full field details (default)"),
93
207
  },
94
- }, async ({ projectKeys, issuetypeIds, expand }) => run(() => client.apiGet("/issue/createmeta", {
95
- projectKeys,
96
- issuetypeIds,
97
- expand: expand ?? "projects.issuetypes.fields",
98
- })));
208
+ }, async ({ projectKeys, issuetypeIds, expand }) => run(() => getIssueCreateMeta(client, projectKeys, issuetypeIds, expand ?? "projects.issuetypes.fields")));
99
209
  server.registerTool("get_issue_transitions", {
100
210
  title: "Get issue transitions",
101
211
  description: "List the workflow transitions available for an issue.",
@@ -113,11 +223,11 @@ export function registerIssueTools(server, client) {
113
223
  },
114
224
  }, async ({ issueKey, transitionId, fields }) => run(() => client.apiPost(`/issue/${encodeURIComponent(issueKey)}/transitions`, {
115
225
  transition: { id: transitionId },
116
- fields,
226
+ fields: richTextFields(fields, client.apiVersion),
117
227
  })));
118
228
  server.registerTool("assign_issue", {
119
229
  title: "Assign issue",
120
- description: "Assign (or unassign, with an empty value) an issue to a user.",
230
+ description: "Assign (or unassign, with an empty value) an issue to a user. Uses PUT /issue/{key}/assignee with accountId on v3 and name on v2.",
121
231
  inputSchema: {
122
232
  issueKey: z.string().describe("Issue key, e.g. PROJ-123"),
123
233
  accountId: z.string().optional().describe("Account ID (Jira Cloud)"),
@@ -125,28 +235,26 @@ export function registerIssueTools(server, client) {
125
235
  },
126
236
  }, async ({ issueKey, accountId, name }) => run(() => {
127
237
  const body = {};
128
- if (accountId)
129
- body.accountId = accountId;
130
- else if (name)
131
- body.name = name;
132
- else if (client.apiVersion === "3")
133
- body.accountId = null;
134
- else
135
- body.name = null;
136
- return client.apiPost(`/issue/${encodeURIComponent(issueKey)}/assignee`, body);
238
+ if (client.apiVersion === "3") {
239
+ body.accountId = accountId ?? name ?? null;
240
+ }
241
+ else {
242
+ body.name = name ?? accountId ?? null;
243
+ }
244
+ return client.apiPut(`/issue/${encodeURIComponent(issueKey)}/assignee`, body);
137
245
  }));
138
246
  server.registerTool("add_comment", {
139
247
  title: "Add comment",
140
248
  description: "Add a comment to an issue.",
141
249
  inputSchema: {
142
250
  issueKey: z.string().describe("Issue key, e.g. PROJ-123"),
143
- body: z.string().describe("Comment text"),
251
+ body: z.string().describe("Comment text (plain text; converted to ADF on Cloud/v3, Markdown is not rendered)"),
144
252
  visibility: z
145
253
  .object({ type: z.string(), value: z.string() })
146
254
  .optional()
147
255
  .describe("Comment visibility restriction"),
148
256
  },
149
- }, async ({ issueKey, body, visibility }) => run(() => client.apiPost(`/issue/${encodeURIComponent(issueKey)}/comment`, { body, visibility })));
257
+ }, async ({ issueKey, body, visibility }) => run(() => client.apiPost(`/issue/${encodeURIComponent(issueKey)}/comment`, { body: richText(body, client.apiVersion), visibility })));
150
258
  server.registerTool("list_comments", {
151
259
  title: "List issue comments",
152
260
  description: "List comments on an issue.",
@@ -177,12 +285,16 @@ export function registerIssueTools(server, client) {
177
285
  }, async ({ issueKey, maxResults, startAt, orderBy }) => run(() => client.apiGet(`/issue/${encodeURIComponent(issueKey)}/worklog`, { maxResults, startAt, orderBy })));
178
286
  server.registerTool("add_worklog", {
179
287
  title: "Add worklog",
180
- description: "Add a worklog (time spent) to an issue. timeSpent uses Jira format, e.g. '2h 30m' or '1d'.",
288
+ description: "Add a worklog (time spent) to an issue. started is optional and defaults to now when omitted; timeSpent uses Jira format, e.g. '2h 30m' or '1d'.",
181
289
  inputSchema: {
182
290
  issueKey: z.string().describe("Issue key, e.g. PROJ-123"),
183
291
  timeSpent: z.string().describe("Time spent, e.g. '1h 30m'"),
184
- comment: z.string().optional(),
185
- started: z.string().optional().describe("Start timestamp (ISO-8601)"),
292
+ comment: z.string().optional().describe("Worklog comment (plain text; converted to ADF on Cloud/v3)"),
293
+ started: z.string().optional().describe("Start timestamp (ISO-8601; Jira defaults it to now when omitted)"),
186
294
  },
187
- }, async ({ issueKey, timeSpent, comment, started }) => run(() => client.apiPost(`/issue/${encodeURIComponent(issueKey)}/worklog`, { timeSpent, comment, started })));
295
+ }, async ({ issueKey, timeSpent, comment, started }) => run(() => client.apiPost(`/issue/${encodeURIComponent(issueKey)}/worklog`, {
296
+ timeSpent,
297
+ comment: richText(comment, client.apiVersion),
298
+ started,
299
+ })));
188
300
  }
@@ -3,12 +3,14 @@ import { run } from "../util.js";
3
3
  export function registerProjectTools(server, client) {
4
4
  server.registerTool("list_projects", {
5
5
  title: "List projects",
6
- description: "List all Jira projects visible to the current user.",
6
+ description: "List all Jira projects visible to the current user. Cloud uses paginated /project/search; Data Center uses /project.",
7
7
  inputSchema: {
8
8
  maxResults: z.number().int().min(1).max(1000).default(100),
9
9
  startAt: z.number().int().min(0).default(0),
10
10
  },
11
- }, async ({ maxResults, startAt }) => run(() => client.apiGet("/project", { maxResults, startAt })));
11
+ }, async ({ maxResults, startAt }) => run(() => client.isCloud
12
+ ? client.apiGet("/project/search", { maxResults, startAt })
13
+ : client.apiGet("/project", { maxResults, startAt })));
12
14
  server.registerTool("get_project", {
13
15
  title: "Get project",
14
16
  description: "Get a single project by key or ID.",
@@ -35,11 +37,18 @@ export function registerProjectTools(server, client) {
35
37
  }, async ({ projectKey, name, description }) => run(() => client.apiPost(`/project/${encodeURIComponent(projectKey)}/components`, { name, description })));
36
38
  server.registerTool("get_project_issue_types", {
37
39
  title: "Get project issue types",
38
- description: "List issue types available in a project.",
40
+ description: "List the issue types available in a project, with their hierarchy levels (epic = 1, story/task = 0, sub-task = -1).",
39
41
  inputSchema: {
40
42
  projectKey: z.string().describe("Project key"),
43
+ maxResults: z.number().int().min(1).max(1000).default(100),
44
+ startAt: z.number().int().min(0).default(0),
41
45
  },
42
- }, async ({ projectKey }) => run(() => client.apiGet(`/project/${encodeURIComponent(projectKey)}/issuetypes`)));
46
+ },
47
+ // /project/{key}/issuetypes does not exist; createmeta is the supported source.
48
+ async ({ projectKey, maxResults, startAt }) => run(() => client.apiGet(`/issue/createmeta/${encodeURIComponent(projectKey)}/issuetypes`, {
49
+ maxResults,
50
+ startAt,
51
+ })));
43
52
  server.registerTool("get_project_roles", {
44
53
  title: "Get project roles",
45
54
  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", {
@@ -29,7 +29,7 @@ export function registerSprintTools(server, client) {
29
29
  endDate: z.string().optional().describe("End date (ISO-8601)"),
30
30
  goal: z.string().optional().describe("Sprint goal"),
31
31
  },
32
- }, async ({ boardId, name, startDate, endDate, goal }) => run(() => client.agilePost(`/board/${boardId}/sprint`, { name, startDate, endDate, goal })));
32
+ }, async ({ boardId, name, startDate, endDate, goal }) => run(() => client.agilePost("/sprint", { name, originBoardId: boardId, startDate, endDate, goal })));
33
33
  server.registerTool("update_sprint", {
34
34
  title: "Update sprint",
35
35
  description: "Update a sprint's name, goal, dates, or state (active/closed/future). Only provided fields are changed.",
@@ -51,7 +51,7 @@ export function registerSprintTools(server, client) {
51
51
  }, async ({ sprintId }) => run(() => client.agilePut(`/sprint/${sprintId}`, { state: "closed" })));
52
52
  server.registerTool("get_sprint_issues", {
53
53
  title: "Get sprint issues",
54
- description: "List the issues in a sprint.",
54
+ description: "List the issues in a sprint. Cloud uses the current Software API enhanced endpoint with cursor paging; Data Center uses Agile 1.0 with startAt/total.",
55
55
  inputSchema: {
56
56
  sprintId: z.number().int().describe("Sprint ID"),
57
57
  fields: z
@@ -59,16 +59,13 @@ export function registerSprintTools(server, client) {
59
59
  .optional()
60
60
  .describe("Comma-separated issue fields to return (default: key, summary, status, assignee)"),
61
61
  maxResults: z.number().int().min(1).max(1000).default(100),
62
- startAt: z.number().int().min(0).default(0),
62
+ startAt: z.number().int().min(0).default(0).describe("Offset paging, Data Center only; rejected on Cloud, use nextPageToken"),
63
+ nextPageToken: z.string().optional().describe("Jira Cloud only: cursor from the previous response"),
63
64
  },
64
- }, async ({ sprintId, fields, maxResults, startAt }) => run(() => client.agileGet(`/sprint/${sprintId}/issue`, {
65
- fields: fields ?? "key,summary,status,assignee",
66
- maxResults,
67
- startAt,
68
- })));
65
+ }, async ({ sprintId, fields, maxResults, startAt, nextPageToken }) => run(() => getSprintIssues(client, sprintId, fields, maxResults, startAt, nextPageToken)));
69
66
  server.registerTool("get_sprint_view", {
70
67
  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).",
68
+ description: "Full sprint view like the Jira UI: board details, the sprint, and its issues, in one call. Cloud issue results use Software API cursor paging; Data Center uses Agile 1.0 offset paging.",
72
69
  inputSchema: {
73
70
  boardId: z.number().int().describe("Board ID"),
74
71
  sprintId: z.number().int().describe("Sprint ID"),
@@ -76,31 +73,62 @@ export function registerSprintTools(server, client) {
76
73
  .string()
77
74
  .optional()
78
75
  .describe("Comma-separated issue fields to return (default: key, summary, status, assignee)"),
76
+ maxResults: z.number().int().min(1).max(1000).default(100),
77
+ startAt: z.number().int().min(0).default(0).describe("Offset paging, Data Center only; rejected on Cloud, use nextPageToken"),
78
+ nextPageToken: z.string().optional().describe("Jira Cloud only: cursor from the previous response"),
79
79
  },
80
- }, async ({ boardId, sprintId, fields }) => run(() => client.agileGet(`/rapid/${rapidViewId(boardId, sprintId)}`, {
81
- fields: fields ?? "key,summary,status,assignee",
82
- })));
80
+ }, async ({ boardId, sprintId, fields, maxResults, startAt, nextPageToken }) => run(async () => {
81
+ const [board, sprint, issues] = await Promise.all([
82
+ client.agileGet(`/board/${boardId}`),
83
+ client.agileGet(`/sprint/${sprintId}`),
84
+ getSprintIssues(client, sprintId, fields, maxResults, startAt, nextPageToken),
85
+ ]);
86
+ return { board, sprint, issues };
87
+ }));
83
88
  server.registerTool("get_backlog", {
84
89
  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.",
90
+ description: "List a board's backlog issues (in the board's filter, not in a sprint), ordered by rank. " +
91
+ "On Cloud this uses the current Software API enhanced backlog endpoint and cursor paging; on Data Center it uses Agile 1.0 with startAt/total.",
86
92
  inputSchema: {
87
93
  boardId: z.number().int().describe("Board ID"),
94
+ jql: z.string().optional().describe("Extra JQL to narrow the backlog, e.g. 'assignee IS EMPTY'"),
88
95
  fields: z
89
96
  .string()
90
97
  .optional()
91
98
  .describe("Comma-separated issue fields to return (default: key, summary, status, assignee)"),
92
99
  maxResults: z.number().int().min(1).max(100).default(50),
93
- startAt: z.number().int().min(0).default(0),
100
+ startAt: z.number().int().min(0).default(0).describe("Offset paging, Data Center only; rejected on Cloud, use nextPageToken"),
101
+ nextPageToken: z.string().optional().describe("Jira Cloud only: cursor from the previous response"),
94
102
  },
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", {
103
+ }, async ({ boardId, jql, fields, maxResults, startAt, nextPageToken }) => run(() => getBacklog(client, boardId, jql, fields, maxResults, startAt, nextPageToken)));
104
+ }
105
+ async function getSprintIssues(client, sprintId, fields, maxResults, startAt, nextPageToken) {
106
+ const issueFields = fields ?? "key,summary,status,assignee";
107
+ if (client.isCloud) {
108
+ if (startAt > 0) {
109
+ throw new Error("Jira Cloud Software sprint issue paging uses nextPageToken, not startAt.");
110
+ }
111
+ return client.softwareGet(`/sprint/${sprintId}/issue`, { fields: issueFields, maxResults, nextPageToken });
112
+ }
113
+ return client.agileGet(`/sprint/${sprintId}/issue`, { fields: issueFields, maxResults, startAt });
114
+ }
115
+ async function getBacklog(client, boardId, jql, fields, maxResults, startAt, nextPageToken) {
116
+ const issueFields = fields ?? "key,summary,status,assignee";
117
+ if (client.isCloud) {
118
+ if (startAt > 0) {
119
+ throw new Error("Jira Cloud Software backlog paging uses nextPageToken, not startAt.");
120
+ }
121
+ return client.softwareGet(`/board/${boardId}/backlog`, {
100
122
  jql,
101
- fields: fields ?? "key,summary,status,assignee",
123
+ fields: issueFields,
102
124
  maxResults,
103
- startAt,
125
+ nextPageToken,
104
126
  });
105
- }));
127
+ }
128
+ return client.agileGet(`/board/${boardId}/backlog`, {
129
+ jql,
130
+ fields: issueFields,
131
+ maxResults,
132
+ startAt,
133
+ });
106
134
  }
@@ -29,7 +29,7 @@ export function registerUserTools(server, client) {
29
29
  if (!accountId) {
30
30
  throw new Error("API v3 requires accountId for get_user. Use find_users to look up the account ID for a username.");
31
31
  }
32
- return client.apiGet(`/user/${encodeURIComponent(accountId)}`);
32
+ return client.apiGet("/user", { accountId });
33
33
  }
34
34
  return client.apiGet("/user", { username: username ?? accountId });
35
35
  }));
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.2.0",
3
+ "version": "0.4.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
@@ -27,7 +27,8 @@ Or non-interactive: `npx -y @thammarongg/jira-mcp install --agents all
27
27
  --yes`. Required env vars:
28
28
 
29
29
  - `JIRA_BASE_URL` — `https://your-org.atlassian.net` (Cloud) or
30
- `https://jira.yourcompany.com` (DC)
30
+ `https://jira.yourcompany.com` (DC); a DC context path such as `/jira` is
31
+ supported and preserved
31
32
  - Cloud: `JIRA_EMAIL` + `JIRA_API_TOKEN` (token from id.atlassian.com →
32
33
  Security → API tokens)
33
34
  - DC: `JIRA_USERNAME` + `JIRA_API_TOKEN` (PAT) or `JIRA_PASSWORD` (app password)
@@ -64,11 +65,17 @@ After setup, verify with the `get_current_user` tool.
64
65
 
65
66
  ## Tool map
66
67
 
67
- - **Boards/sprints**: `list_boards`, `get_board`, `list_sprints`, `get_sprint`,
68
+ - **Boards/sprints**: `list_boards` (the project filter is one Agile
69
+ `projectKeyOrId` value), `get_board`, `list_sprints`, `get_sprint`,
68
70
  `create_sprint`, `update_sprint`, `close_sprint`, `get_sprint_issues`,
69
- `get_sprint_view` (UI-like full view), `get_backlog`
70
- - **Epics**: `list_epics`, `get_epic`, `get_epic_issues`, `create_epic`,
71
- `move_issue_to_epic`, `get_epic_meta`
71
+ `get_sprint_view` (UI-like full view), `get_backlog`. Cloud backlog and
72
+ issue-list reads use Software 1.0 enhanced endpoints with cursor paging;
73
+ Data Center uses Agile 1.0 with `startAt`/`total`. `create_sprint` sends
74
+ `originBoardId` to the top-level Agile sprint-create route.
75
+ - **Epics**: `list_epics` (by board), `get_epic`, `get_epic_issues`,
76
+ `create_epic`, `move_issue_to_epic`, `get_epic_meta` — epics are addressed by
77
+ key or numeric ID. Cloud `get_epic_issues` uses Software 1.0 enhanced
78
+ cursor paging; team-managed projects fall back to JQL/search as needed.
72
79
  - **Issues**: `get_issue`, `create_issue`, `update_issue`, `delete_issue`,
73
80
  `search_issues` (JQL), `get_issue_create_meta`, `get_issue_transitions`,
74
81
  `transition_issue`, `assign_issue`, `add_comment`, `list_comments`,
@@ -84,17 +91,35 @@ After setup, verify with the `get_current_user` tool.
84
91
  **Sprint status**: `list_boards` → pick board → `list_sprints` (state
85
92
  `active`) → `get_sprint_issues` or `get_sprint_view` for the full picture.
86
93
 
87
- **Backlog review**: `get_backlog(boardId)` — issues in the board's projects
88
- with no sprint, ordered by rank.
94
+ **Backlog review**: `get_backlog(boardId)` — issues on the board that are not
95
+ in a sprint, ordered by rank; pass `jql` to narrow it. On Cloud, pass the
96
+ returned `nextPageToken` for the next page; on Data Center, pass `startAt`.
97
+
98
+ **Epic breakdown**: `list_epics(boardId)` → `get_epic_issues(epicIdOrKey)` for
99
+ the children. To create one, `get_epic_meta(projectKey)` gives the epic issue
100
+ type and then `create_epic`; company-managed projects also need the "Epic Name"
101
+ custom field via `customFields`.
89
102
 
90
103
  **Create an issue**: call `get_issue_create_meta(projectKeys=...)` first to
91
104
  discover valid issue types and required fields, then `create_issue`
92
105
  (custom fields go in `customFields`, e.g. `{ "customfield_10010": "..." }`).
106
+ The metadata tool composes the current per-project and per-issue-type
107
+ endpoints; the deprecated `/issue/createmeta` query form is not used. Pass
108
+ `projectKeys` when fields are expanded (the default); omit it only with
109
+ `expand: "projects.issuetypes"` for issue-type summaries. Metadata requests
110
+ use a fixed concurrency limit of four.
111
+
112
+ **Comments and descriptions**: pass plain text — `add_comment`, `create_issue`,
113
+ `create_epic`, `update_issue` and `add_worklog` convert it to ADF automatically
114
+ on Jira Cloud (v3). Blank lines separate paragraphs; Markdown is not rendered.
115
+ No need to hand-build an ADF document or fall back to `jira_api`.
93
116
 
94
117
  **Move work through the workflow**: `get_issue_transitions(issueKey)` to see
95
118
  available transitions and required fields, then `transition_issue`.
96
119
 
97
- **Search**: `search_issues` with JQL. Common queries:
120
+ **Search**: `search_issues` with JQL. On Jira Cloud the query must be
121
+ **bounded** — always include a restriction such as `project`, `assignee`, or
122
+ `key`; a bare `ORDER BY created DESC` is rejected. Common queries:
98
123
  - Current sprint: `project = PROJ AND sprint = <sprintId> ORDER BY rank`
99
124
  - Unassigned in project: `project = PROJ AND assignee IS EMPTY`
100
125
  - Due this week: `project = PROJ AND duedate <= endOfWeek() ORDER BY duedate`
@@ -104,8 +129,22 @@ available transitions and required fields, then `transition_issue`.
104
129
 
105
130
  - List tools paginate with `startAt`/`maxResults`; responses include `total` —
106
131
  page with `startAt` when `total` exceeds the page size.
132
+ - `search_issues` is the exception on **Jira Cloud**: it uses Atlassian's
133
+ enhanced search (`/search/jql`, which replaced the removed `/search`). There
134
+ is no `total`; page by passing the response's `nextPageToken` back in and
135
+ stop when `isLast` is true — `startAt` is rejected. Pass
136
+ `includeApproximateTotal: true` for a rough match count (one extra request).
137
+ On Data Center it still uses `startAt`/`total`.
107
138
  - Assignees: pass an account ID on Cloud, a username on DC — the server maps
108
139
  it to the right field shape automatically.
140
+ - `get_user` uses `/user?accountId=...` on Cloud/v3 and `/user?username=...` on
141
+ Data Center/v2. `create_issue` maps its `dueDate` input to Jira's `duedate`
142
+ field, and `add_worklog` accepts an optional `started` timestamp (Jira
143
+ defaults it to now when omitted).
144
+ - Data Center exposes REST API v2/latest, not `/rest/api/3`; do not force
145
+ `JIRA_API_VERSION=3` for a DC host. The default deployment detection routes
146
+ Cloud (`*.atlassian.net`) and DC endpoints separately, while ADF and user
147
+ field shapes follow the selected API version.
109
148
  - To remove an issue from an epic: `update_issue` with
110
149
  `fields: { "epic": null }`.
111
150
  - Anything not covered by a dedicated tool → `jira_api` with method, path