@thammarongg/jira-mcp 0.3.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,20 +160,20 @@ 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 |
174
+ | `get_sprint_issues` | Issues in a sprint (Cloud cursor or DC offset paging) |
175
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) |
176
+ | `get_backlog` | Board backlog (Cloud Software enhanced or DC Agile, rank-ordered) |
177
177
 
178
178
  ### Epics
179
179
 
@@ -194,10 +194,10 @@ mkdir -p ~/.agents/skills/jira && cp skill/SKILL.md ~/.agents/skills/jira/
194
194
  | `update_issue` | Set fields and/or relative `update` ops |
195
195
  | `delete_issue` | Delete |
196
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 |
197
+ | `get_issue_create_meta` | Discover projects/types/required fields (current per-project metadata endpoints) |
198
198
  | `get_issue_transitions` / `transition_issue` | Workflow transitions |
199
199
  | `assign_issue` | Assign/unassign |
200
- | `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) |
201
201
  | `get_issue_worklogs` / `add_worklog` | Time tracking |
202
202
 
203
203
  ### Projects, users, meta
@@ -219,6 +219,17 @@ mkdir -p ~/.agents/skills/jira && cp skill/SKILL.md ~/.agents/skills/jira/
219
219
  5. `create_issue` / `transition_issue` / `add_comment` to act
220
220
  6. Anything else → `jira_api`
221
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
+
222
233
  ## Development
223
234
 
224
235
  ```bash
@@ -231,11 +242,17 @@ node scripts/smoke.mjs # stdio handshake + tools/list smoke test
231
242
  ## Notes & limitations
232
243
 
233
244
  - Auth is HTTP Basic (email+token for Cloud, username+token/password for DC) — the standard for Jira REST.
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.
234
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`.
235
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`.
236
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`.
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.
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.
238
253
  - Rapid view IDs are computed as `boardId * 10^13 + sprintId` (Jira's documented convention).
239
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.
240
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.
241
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.3.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);
@@ -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.",
@@ -2,13 +2,16 @@ import { z } from "zod";
2
2
  import { JiraApiError } from "../client.js";
3
3
  import { run } from "../util.js";
4
4
  import { searchIssues } from "../search.js";
5
+ import { richText } from "../adf.js";
5
6
  const DEFAULT_EPIC_ISSUE_FIELDS = "key,summary,status,assignee";
6
7
  /**
7
8
  * The Agile epic endpoints (/rest/agile/1.0/epic/...) only understand classic
8
9
  * (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.
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.
12
15
  */
13
16
  function isNextGenRejection(err) {
14
17
  return err instanceof JiraApiError && (err.status === 400 || err.status === 404);
@@ -50,7 +53,7 @@ export function registerEpicTools(server, client) {
50
53
  }));
51
54
  server.registerTool("get_epic_issues", {
52
55
  title: "Get epic issues",
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.",
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.",
54
57
  inputSchema: {
55
58
  epicIdOrKey: z.string().describe("Epic key, e.g. PROJ-1, or numeric issue ID"),
56
59
  fields: z
@@ -66,11 +69,23 @@ export function registerEpicTools(server, client) {
66
69
  },
67
70
  }, async ({ epicIdOrKey, fields, maxResults, startAt, nextPageToken }) => run(() => {
68
71
  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, {
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, {
74
89
  // JQL `parent` accepts a key or a numeric issue ID.
75
90
  jql: `parent = ${epicIdOrKey} ORDER BY rank`,
76
91
  fields: issueFields,
@@ -85,7 +100,10 @@ export function registerEpicTools(server, client) {
85
100
  inputSchema: {
86
101
  projectKey: z.string().describe("Project key, e.g. PROJ"),
87
102
  name: z.string().describe("Epic name (used as the issue summary)"),
88
- description: z.string().optional(),
103
+ description: z
104
+ .string()
105
+ .optional()
106
+ .describe("Plain text; converted to ADF on Cloud/v3. Markdown is not rendered."),
89
107
  issueType: z
90
108
  .string()
91
109
  .optional()
@@ -103,7 +121,7 @@ export function registerEpicTools(server, client) {
103
121
  project: { key: projectKey },
104
122
  summary: name,
105
123
  issuetype: /^\d+$/.test(type) ? { id: type } : { name: type },
106
- description,
124
+ description: richText(description, client.apiVersion),
107
125
  assignee: assignee
108
126
  ? client.apiVersion === "3"
109
127
  ? { accountId: assignee }
@@ -130,7 +148,7 @@ export function registerEpicTools(server, client) {
130
148
  })));
131
149
  server.registerTool("get_epic_meta", {
132
150
  title: "Get epic meta",
133
- description: "List the epic-level issue types available in a project (hierarchy level above Story/Task), with the fields required to create one.",
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.",
134
152
  inputSchema: {
135
153
  projectKey: z.string().describe("Project key, e.g. PROJ"),
136
154
  },
@@ -1,7 +1,92 @@
1
1
  import { z } from "zod";
2
2
  import { run } from "../util.js";
3
3
  import { searchIssues } from "../search.js";
4
- const DEFAULT_ISSUE_FIELDS = "summary,description,status,assignee,reporter,issuetype,labels,components,priority,created,updated,due,project";
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
+ }
5
90
  export function registerIssueTools(server, client) {
6
91
  server.registerTool("get_issue", {
7
92
  title: "Get issue",
@@ -9,7 +94,10 @@ export function registerIssueTools(server, client) {
9
94
  inputSchema: {
10
95
  issueKey: z.string().describe("Issue key, e.g. PROJ-123"),
11
96
  fields: z.string().optional().describe(`Comma-separated fields (default: ${DEFAULT_ISSUE_FIELDS})`),
12
- 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"),
13
101
  },
14
102
  }, async ({ issueKey, fields, expand }) => run(() => client.apiGet(`/issue/${encodeURIComponent(issueKey)}`, { fields: fields ?? DEFAULT_ISSUE_FIELDS, expand })));
15
103
  server.registerTool("create_issue", {
@@ -19,12 +107,15 @@ export function registerIssueTools(server, client) {
19
107
  projectKey: z.string().describe("Project key, e.g. PROJ"),
20
108
  summary: z.string().describe("Issue summary"),
21
109
  issueType: z.string().describe("Issue type name or ID, e.g. Story, Task, Bug, or 10004"),
22
- 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."),
23
114
  assignee: z.string().optional().describe("Account ID on Cloud/v3, username on DC/v2"),
24
115
  reporter: z.string().optional().describe("Account ID on Cloud/v3, username on DC/v2"),
25
116
  labels: z.array(z.string()).optional(),
26
117
  components: z.array(z.string()).optional().describe("Component names"),
27
- 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)"),
28
119
  priority: z.string().optional().describe("Priority name, e.g. High"),
29
120
  parent: z.string().optional().describe("Parent issue key (for sub-tasks)"),
30
121
  customFields: z.record(z.string(), z.unknown()).optional().describe("Custom field IDs mapped to values"),
@@ -36,12 +127,12 @@ export function registerIssueTools(server, client) {
36
127
  project: { key: projectKey },
37
128
  summary,
38
129
  issuetype: /^\d+$/.test(issueType) ? { id: issueType } : { name: issueType },
39
- description,
130
+ description: richText(description, client.apiVersion),
40
131
  assignee: assignee ? userRef(assignee) : undefined,
41
132
  reporter: reporter ? userRef(reporter) : undefined,
42
133
  labels,
43
134
  components: components?.map((name) => ({ name })),
44
- dueDate,
135
+ duedate: dueDate,
45
136
  priority: priority ? { name: priority } : undefined,
46
137
  parent: parent ? { key: parent } : undefined,
47
138
  ...customFields,
@@ -53,10 +144,16 @@ export function registerIssueTools(server, client) {
53
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' }] }).",
54
145
  inputSchema: {
55
146
  issueKey: z.string().describe("Issue key, e.g. PROJ-123"),
56
- 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."),
57
151
  update: z.record(z.string(), z.unknown()).optional().describe("Relative updates (add/remove operations)"),
58
152
  },
59
- }, 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
+ })));
60
157
  server.registerTool("delete_issue", {
61
158
  title: "Delete issue",
62
159
  description: "Delete an issue. This is destructive.",
@@ -96,20 +193,19 @@ export function registerIssueTools(server, client) {
96
193
  })));
97
194
  server.registerTool("get_issue_create_meta", {
98
195
  title: "Get issue create metadata",
99
- 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.",
100
197
  inputSchema: {
101
- 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)"),
102
202
  issuetypeIds: z.string().optional().describe("Comma-separated issue type IDs to filter"),
103
203
  expand: z
104
204
  .string()
105
205
  .optional()
106
206
  .describe("Use 'projects.issuetypes.fields' for full field details (default)"),
107
207
  },
108
- }, async ({ projectKeys, issuetypeIds, expand }) => run(() => client.apiGet("/issue/createmeta", {
109
- projectKeys,
110
- issuetypeIds,
111
- expand: expand ?? "projects.issuetypes.fields",
112
- })));
208
+ }, async ({ projectKeys, issuetypeIds, expand }) => run(() => getIssueCreateMeta(client, projectKeys, issuetypeIds, expand ?? "projects.issuetypes.fields")));
113
209
  server.registerTool("get_issue_transitions", {
114
210
  title: "Get issue transitions",
115
211
  description: "List the workflow transitions available for an issue.",
@@ -127,11 +223,11 @@ export function registerIssueTools(server, client) {
127
223
  },
128
224
  }, async ({ issueKey, transitionId, fields }) => run(() => client.apiPost(`/issue/${encodeURIComponent(issueKey)}/transitions`, {
129
225
  transition: { id: transitionId },
130
- fields,
226
+ fields: richTextFields(fields, client.apiVersion),
131
227
  })));
132
228
  server.registerTool("assign_issue", {
133
229
  title: "Assign issue",
134
- 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.",
135
231
  inputSchema: {
136
232
  issueKey: z.string().describe("Issue key, e.g. PROJ-123"),
137
233
  accountId: z.string().optional().describe("Account ID (Jira Cloud)"),
@@ -139,28 +235,26 @@ export function registerIssueTools(server, client) {
139
235
  },
140
236
  }, async ({ issueKey, accountId, name }) => run(() => {
141
237
  const body = {};
142
- if (accountId)
143
- body.accountId = accountId;
144
- else if (name)
145
- body.name = name;
146
- else if (client.apiVersion === "3")
147
- body.accountId = null;
148
- else
149
- body.name = null;
150
- 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);
151
245
  }));
152
246
  server.registerTool("add_comment", {
153
247
  title: "Add comment",
154
248
  description: "Add a comment to an issue.",
155
249
  inputSchema: {
156
250
  issueKey: z.string().describe("Issue key, e.g. PROJ-123"),
157
- 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)"),
158
252
  visibility: z
159
253
  .object({ type: z.string(), value: z.string() })
160
254
  .optional()
161
255
  .describe("Comment visibility restriction"),
162
256
  },
163
- }, 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 })));
164
258
  server.registerTool("list_comments", {
165
259
  title: "List issue comments",
166
260
  description: "List comments on an issue.",
@@ -191,12 +285,16 @@ export function registerIssueTools(server, client) {
191
285
  }, async ({ issueKey, maxResults, startAt, orderBy }) => run(() => client.apiGet(`/issue/${encodeURIComponent(issueKey)}/worklog`, { maxResults, startAt, orderBy })));
192
286
  server.registerTool("add_worklog", {
193
287
  title: "Add worklog",
194
- 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'.",
195
289
  inputSchema: {
196
290
  issueKey: z.string().describe("Issue key, e.g. PROJ-123"),
197
291
  timeSpent: z.string().describe("Time spent, e.g. '1h 30m'"),
198
- comment: z.string().optional(),
199
- 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)"),
200
294
  },
201
- }, 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
+ })));
202
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.",
@@ -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 details, the sprint, and its issues, in one call.",
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"),
@@ -77,26 +74,21 @@ export function registerSprintTools(server, client) {
77
74
  .optional()
78
75
  .describe("Comma-separated issue fields to return (default: key, summary, status, assignee)"),
79
76
  maxResults: z.number().int().min(1).max(1000).default(100),
80
- startAt: z.number().int().min(0).default(0),
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"),
81
79
  },
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.
80
+ }, async ({ boardId, sprintId, fields, maxResults, startAt, nextPageToken }) => run(async () => {
85
81
  const [board, sprint, issues] = await Promise.all([
86
82
  client.agileGet(`/board/${boardId}`),
87
83
  client.agileGet(`/sprint/${sprintId}`),
88
- client.agileGet(`/sprint/${sprintId}/issue`, {
89
- fields: fields ?? "key,summary,status,assignee",
90
- maxResults,
91
- startAt,
92
- }),
84
+ getSprintIssues(client, sprintId, fields, maxResults, startAt, nextPageToken),
93
85
  ]);
94
86
  return { board, sprint, issues };
95
87
  }));
96
88
  server.registerTool("get_backlog", {
97
89
  title: "Get board backlog",
98
90
  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.",
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.",
100
92
  inputSchema: {
101
93
  boardId: z.number().int().describe("Board ID"),
102
94
  jql: z.string().optional().describe("Extra JQL to narrow the backlog, e.g. 'assignee IS EMPTY'"),
@@ -105,12 +97,38 @@ export function registerSprintTools(server, client) {
105
97
  .optional()
106
98
  .describe("Comma-separated issue fields to return (default: key, summary, status, assignee)"),
107
99
  maxResults: z.number().int().min(1).max(100).default(50),
108
- 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"),
109
102
  },
110
- }, async ({ boardId, jql, fields, maxResults, startAt }) => run(() => client.agileGet(`/board/${boardId}/backlog`, {
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`, {
122
+ jql,
123
+ fields: issueFields,
124
+ maxResults,
125
+ nextPageToken,
126
+ });
127
+ }
128
+ return client.agileGet(`/board/${boardId}/backlog`, {
111
129
  jql,
112
- fields: fields ?? "key,summary,status,assignee",
130
+ fields: issueFields,
113
131
  maxResults,
114
132
  startAt,
115
- })));
133
+ });
116
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thammarongg/jira-mcp",
3
- "version": "0.3.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": {
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,12 +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`
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.
70
75
  - **Epics**: `list_epics` (by board), `get_epic`, `get_epic_issues`,
71
76
  `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
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.
73
79
  - **Issues**: `get_issue`, `create_issue`, `update_issue`, `delete_issue`,
74
80
  `search_issues` (JQL), `get_issue_create_meta`, `get_issue_transitions`,
75
81
  `transition_issue`, `assign_issue`, `add_comment`, `list_comments`,
@@ -86,7 +92,8 @@ After setup, verify with the `get_current_user` tool.
86
92
  `active`) → `get_sprint_issues` or `get_sprint_view` for the full picture.
87
93
 
88
94
  **Backlog review**: `get_backlog(boardId)` — issues on the board that are not
89
- in a sprint, ordered by rank; pass `jql` to narrow it.
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`.
90
97
 
91
98
  **Epic breakdown**: `list_epics(boardId)` → `get_epic_issues(epicIdOrKey)` for
92
99
  the children. To create one, `get_epic_meta(projectKey)` gives the epic issue
@@ -96,6 +103,16 @@ custom field via `customFields`.
96
103
  **Create an issue**: call `get_issue_create_meta(projectKeys=...)` first to
97
104
  discover valid issue types and required fields, then `create_issue`
98
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`.
99
116
 
100
117
  **Move work through the workflow**: `get_issue_transitions(issueKey)` to see
101
118
  available transitions and required fields, then `transition_issue`.
@@ -120,6 +137,14 @@ available transitions and required fields, then `transition_issue`.
120
137
  On Data Center it still uses `startAt`/`total`.
121
138
  - Assignees: pass an account ID on Cloud, a username on DC — the server maps
122
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.
123
148
  - To remove an issue from an epic: `update_issue` with
124
149
  `fields: { "epic": null }`.
125
150
  - Anything not covered by a dedicated tool → `jira_api` with method, path