@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 +36 -15
- package/dist/adf.js +71 -0
- package/dist/client.js +6 -1
- package/dist/config.js +1 -1
- package/dist/index.js +1 -1
- package/dist/install.js +10 -4
- package/dist/search.js +56 -0
- package/dist/tools/boards.js +3 -3
- package/dist/tools/epics.js +137 -30
- package/dist/tools/issues.js +148 -36
- package/dist/tools/projects.js +13 -4
- package/dist/tools/sprints.js +51 -23
- package/dist/tools/users.js +1 -1
- package/dist/util.js +0 -3
- package/package.json +2 -2
- package/skill/SKILL.md +47 -8
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
|
|
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 (
|
|
176
|
-
| `get_backlog` | Board backlog
|
|
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`
|
|
183
|
-
| `
|
|
184
|
-
| `
|
|
185
|
-
| `
|
|
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**
|
|
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
|
-
-
|
|
234
|
-
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
228
|
-
|
|
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 (
|
|
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
|
+
}
|
package/dist/tools/boards.js
CHANGED
|
@@ -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
|
|
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("
|
|
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.",
|
package/dist/tools/epics.js
CHANGED
|
@@ -1,61 +1,168 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
2
|
+
import { JiraApiError } from "../client.js";
|
|
3
|
+
import { run } from "../util.js";
|
|
4
|
+
import { searchIssues } from "../search.js";
|
|
5
|
+
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
48
|
+
epicIdOrKey: z.string().describe("Epic key, e.g. PROJ-1, or numeric issue ID"),
|
|
19
49
|
},
|
|
20
|
-
}, async ({
|
|
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
|
-
|
|
26
|
-
|
|
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 ({
|
|
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
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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 ({
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
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
|
-
|
|
50
|
-
|
|
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 ({
|
|
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: "
|
|
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().
|
|
153
|
+
projectKey: z.string().describe("Project key, e.g. PROJ"),
|
|
59
154
|
},
|
|
60
|
-
}, async ({ projectKey }) => run(() =>
|
|
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
|
}
|
package/dist/tools/issues.js
CHANGED
|
@@ -1,6 +1,92 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { run } from "../util.js";
|
|
3
|
-
|
|
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
|
|
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
|
|
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
|
|
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)}`, {
|
|
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'.
|
|
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
|
|
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
|
|
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.
|
|
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 (
|
|
129
|
-
body.accountId = accountId;
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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`, {
|
|
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
|
}
|
package/dist/tools/projects.js
CHANGED
|
@@ -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.
|
|
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
|
-
},
|
|
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.",
|
package/dist/tools/sprints.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
2
|
+
import { run } from "../util.js";
|
|
3
3
|
const sprintState = z.enum(["active", "closed", "future"]).describe("Sprint state filter");
|
|
4
4
|
export function registerSprintTools(server, client) {
|
|
5
5
|
server.registerTool("list_sprints", {
|
|
@@ -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(
|
|
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
|
|
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
|
|
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(() =>
|
|
81
|
-
|
|
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
|
|
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(
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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:
|
|
123
|
+
fields: issueFields,
|
|
102
124
|
maxResults,
|
|
103
|
-
|
|
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
|
}
|
package/dist/tools/users.js
CHANGED
|
@@ -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(
|
|
32
|
+
return client.apiGet("/user", { accountId });
|
|
33
33
|
}
|
|
34
34
|
return client.apiGet("/user", { username: username ?? accountId });
|
|
35
35
|
}));
|
package/dist/util.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thammarongg/jira-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.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
|
|
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
|
-
-
|
|
71
|
-
`
|
|
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
|
|
88
|
-
|
|
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.
|
|
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
|