@thammarongg/jira-mcp 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -9
- package/dist/index.js +1 -1
- package/dist/install.js +10 -4
- package/dist/search.js +56 -0
- package/dist/tools/epics.js +118 -29
- package/dist/tools/issues.js +18 -4
- package/dist/tools/projects.js +9 -2
- package/dist/tools/sprints.js +27 -17
- package/dist/util.js +0 -3
- package/package.json +2 -2
- package/skill/SKILL.md +19 -5
package/README.md
CHANGED
|
@@ -172,17 +172,18 @@ mkdir -p ~/.agents/skills/jira && cp skill/SKILL.md ~/.agents/skills/jira/
|
|
|
172
172
|
| `update_sprint` | Rename, reschedule, change goal/state |
|
|
173
173
|
| `close_sprint` | Close a sprint |
|
|
174
174
|
| `get_sprint_issues` | Issues in a sprint |
|
|
175
|
-
| `get_sprint_view` | Full UI-like sprint view (
|
|
176
|
-
| `get_backlog` | Board backlog
|
|
175
|
+
| `get_sprint_view` | Full UI-like sprint view (board + sprint + issues in one call) |
|
|
176
|
+
| `get_backlog` | Board backlog (Agile `/board/{id}/backlog`, rank-ordered) |
|
|
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,7 +193,7 @@ 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
|
+
| `search_issues` | **JQL search** — enhanced search (`/search/jql`) on Cloud, legacy `/search` on Data Center |
|
|
196
197
|
| `get_issue_create_meta` | Discover projects/types/required fields |
|
|
197
198
|
| `get_issue_transitions` / `transition_issue` | Workflow transitions |
|
|
198
199
|
| `assign_issue` | Assign/unassign |
|
|
@@ -230,8 +231,11 @@ node scripts/smoke.mjs # stdio handshake + tools/list smoke test
|
|
|
230
231
|
## Notes & limitations
|
|
231
232
|
|
|
232
233
|
- Auth is HTTP Basic (email+token for Cloud, username+token/password for DC) — the standard for Jira REST.
|
|
233
|
-
- Pagination: list tools return Jira's native `startAt`/`maxResults`/`total`; pass `startAt` to page.
|
|
234
|
-
- `
|
|
234
|
+
- Pagination: most list tools return Jira's native `startAt`/`maxResults`/`total`; pass `startAt` to page.
|
|
235
|
+
- JQL search on **Jira Cloud** uses `/rest/api/3/search/jql`, since Atlassian removed `GET /rest/api/{2,3}/search` on 2025-05-01 ([CHANGE-2046](https://developer.atlassian.com/changelog/#CHANGE-2046) — the old endpoint now returns HTTP 410). Consequences for `search_issues` on Cloud: the JQL must be **bounded** (include a restriction such as `project`, `assignee`, or `key`), the response carries **no `total`**, and paging is by cursor — pass the returned `nextPageToken` back and stop when `isLast` is true. `startAt` is rejected there rather than silently ignored, and `includeApproximateTotal: true` adds an approximate match count via `/search/approximate-count`.
|
|
236
|
+
- Jira **Data Center** keeps the legacy `/search` endpoint with `startAt`/`total`; if a Cloud site on a custom domain is misdetected as DC, a 410 from `/search` transparently retries against `/search/jql`.
|
|
237
|
+
- `get_backlog` calls the Agile API's `/board/{id}/backlog` endpoint, so it keeps `startAt`/`total` paging on Cloud and DC alike; pass `jql` to narrow it further.
|
|
235
238
|
- Rapid view IDs are computed as `boardId * 10^13 + sprintId` (Jira's documented convention).
|
|
236
239
|
- Comment bodies use the `body` field on both Cloud (v3) and Data Center (v2).
|
|
240
|
+
- Epics: the Agile epic API (`/rest/agile/1.0/epic/...`) only understands company-managed epics and returns HTTP 400 on team-managed ("next-gen") projects. `get_epic`, `get_epic_issues`, and `move_issue_to_epic` detect that and fall back to the issue/search APIs, where an epic is an ordinary issue linked to its children by `parent`.
|
|
237
241
|
- `jira_api` paths must resolve under `/rest/` — paths that would escape it (e.g. via `..` segments) are rejected, and `?`/`#` must be passed via `query`.
|
package/dist/index.js
CHANGED
|
@@ -19,7 +19,7 @@ async function main() {
|
|
|
19
19
|
}
|
|
20
20
|
const config = loadConfig();
|
|
21
21
|
const client = new JiraClient(config);
|
|
22
|
-
const server = new McpServer({ name: "jira", version: "0.
|
|
22
|
+
const server = new McpServer({ name: "jira", version: "0.3.0" });
|
|
23
23
|
registerBoardTools(server, client);
|
|
24
24
|
registerSprintTools(server, client);
|
|
25
25
|
registerEpicTools(server, client);
|
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/epics.js
CHANGED
|
@@ -1,61 +1,150 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
2
|
+
import { JiraApiError } from "../client.js";
|
|
3
|
+
import { run } from "../util.js";
|
|
4
|
+
import { searchIssues } from "../search.js";
|
|
5
|
+
const DEFAULT_EPIC_ISSUE_FIELDS = "key,summary,status,assignee";
|
|
6
|
+
/**
|
|
7
|
+
* The Agile epic endpoints (/rest/agile/1.0/epic/...) only understand classic
|
|
8
|
+
* (company-managed) epics: on a team-managed ("next-gen") project they answer
|
|
9
|
+
* HTTP 400 "The request contains a next-gen issue". There, an epic is an ordinary
|
|
10
|
+
* issue and its children are linked by `parent`, so each tool falls back to the
|
|
11
|
+
* plain issue/search APIs when the Agile call rejects the project style.
|
|
12
|
+
*/
|
|
13
|
+
function isNextGenRejection(err) {
|
|
14
|
+
return err instanceof JiraApiError && (err.status === 400 || err.status === 404);
|
|
15
|
+
}
|
|
16
|
+
async function withNextGenFallback(agile, fallback) {
|
|
17
|
+
try {
|
|
18
|
+
return await agile();
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
if (!isNextGenRejection(err))
|
|
22
|
+
throw err;
|
|
23
|
+
return fallback();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
3
26
|
export function registerEpicTools(server, client) {
|
|
4
27
|
server.registerTool("list_epics", {
|
|
5
28
|
title: "List epics",
|
|
6
|
-
description: "List epics
|
|
29
|
+
description: "List the epics on a board, including whether each one is done.",
|
|
7
30
|
inputSchema: {
|
|
8
31
|
boardId: z.number().int().describe("Board ID"),
|
|
9
|
-
|
|
32
|
+
done: z.boolean().optional().describe("Filter by completion state; omit for all epics"),
|
|
10
33
|
maxResults: z.number().int().min(1).max(1000).default(100),
|
|
11
34
|
startAt: z.number().int().min(0).default(0),
|
|
12
35
|
},
|
|
13
|
-
}, async ({ boardId,
|
|
36
|
+
}, async ({ boardId, done, maxResults, startAt }) => run(() => client.agileGet(`/board/${boardId}/epic`, {
|
|
37
|
+
done: done === undefined ? undefined : String(done),
|
|
38
|
+
maxResults,
|
|
39
|
+
startAt,
|
|
40
|
+
})));
|
|
14
41
|
server.registerTool("get_epic", {
|
|
15
42
|
title: "Get epic",
|
|
16
|
-
description: "Get a single epic by
|
|
43
|
+
description: "Get a single epic by key (e.g. PROJ-1) or numeric issue ID. Falls back to the issue API for team-managed projects, where epics are ordinary issues.",
|
|
17
44
|
inputSchema: {
|
|
18
|
-
|
|
45
|
+
epicIdOrKey: z.string().describe("Epic key, e.g. PROJ-1, or numeric issue ID"),
|
|
19
46
|
},
|
|
20
|
-
}, async ({
|
|
47
|
+
}, async ({ epicIdOrKey }) => run(() => {
|
|
48
|
+
const id = encodeURIComponent(epicIdOrKey);
|
|
49
|
+
return withNextGenFallback(() => client.agileGet(`/epic/${id}`), () => client.apiGet(`/issue/${id}`, { fields: "summary,status,assignee,issuetype,project,description" }));
|
|
50
|
+
}));
|
|
21
51
|
server.registerTool("get_epic_issues", {
|
|
22
52
|
title: "Get epic issues",
|
|
23
|
-
description: "List the issues belonging to an epic.",
|
|
53
|
+
description: "List the issues belonging to an epic. Falls back to a JQL 'parent = <epic>' search on team-managed projects, where the Agile epic API does not apply.",
|
|
24
54
|
inputSchema: {
|
|
25
|
-
|
|
26
|
-
|
|
55
|
+
epicIdOrKey: z.string().describe("Epic key, e.g. PROJ-1, or numeric issue ID"),
|
|
56
|
+
fields: z
|
|
57
|
+
.string()
|
|
58
|
+
.optional()
|
|
59
|
+
.describe(`Comma-separated issue fields (default: ${DEFAULT_EPIC_ISSUE_FIELDS})`),
|
|
60
|
+
maxResults: z.number().int().min(1).max(100).default(100),
|
|
27
61
|
startAt: z.number().int().min(0).default(0),
|
|
62
|
+
nextPageToken: z
|
|
63
|
+
.string()
|
|
64
|
+
.optional()
|
|
65
|
+
.describe("Jira Cloud only: cursor returned by a previous fallback search"),
|
|
28
66
|
},
|
|
29
|
-
}, async ({
|
|
67
|
+
}, async ({ epicIdOrKey, fields, maxResults, startAt, nextPageToken }) => run(() => {
|
|
68
|
+
const issueFields = fields ?? DEFAULT_EPIC_ISSUE_FIELDS;
|
|
69
|
+
return withNextGenFallback(() => client.agileGet(`/epic/${encodeURIComponent(epicIdOrKey)}/issue`, {
|
|
70
|
+
fields: issueFields,
|
|
71
|
+
maxResults,
|
|
72
|
+
startAt,
|
|
73
|
+
}), () => searchIssues(client, {
|
|
74
|
+
// JQL `parent` accepts a key or a numeric issue ID.
|
|
75
|
+
jql: `parent = ${epicIdOrKey} ORDER BY rank`,
|
|
76
|
+
fields: issueFields,
|
|
77
|
+
maxResults,
|
|
78
|
+
startAt,
|
|
79
|
+
nextPageToken,
|
|
80
|
+
}));
|
|
81
|
+
}));
|
|
30
82
|
server.registerTool("create_epic", {
|
|
31
83
|
title: "Create epic",
|
|
32
|
-
description: "Create
|
|
84
|
+
description: "Create an epic in a project. Company-managed projects usually also require the 'Epic Name' custom field — call get_epic_meta or get_issue_create_meta first and pass it via customFields.",
|
|
33
85
|
inputSchema: {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
name: z.string().describe("Epic name"),
|
|
86
|
+
projectKey: z.string().describe("Project key, e.g. PROJ"),
|
|
87
|
+
name: z.string().describe("Epic name (used as the issue summary)"),
|
|
37
88
|
description: z.string().optional(),
|
|
38
|
-
|
|
89
|
+
issueType: z
|
|
90
|
+
.string()
|
|
91
|
+
.optional()
|
|
92
|
+
.describe("Epic issue type name or ID (default: Epic); see get_epic_meta"),
|
|
93
|
+
assignee: z.string().optional().describe("Account ID on Cloud/v3, username on DC/v2"),
|
|
94
|
+
customFields: z
|
|
95
|
+
.record(z.string(), z.unknown())
|
|
96
|
+
.optional()
|
|
97
|
+
.describe("Extra fields, e.g. { 'customfield_10011': 'Epic name' } for company-managed projects"),
|
|
39
98
|
},
|
|
40
|
-
}, async ({
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
99
|
+
}, async ({ projectKey, name, description, issueType, assignee, customFields }) => run(() => {
|
|
100
|
+
const type = issueType ?? "Epic";
|
|
101
|
+
return client.apiPost("/issue", {
|
|
102
|
+
fields: {
|
|
103
|
+
project: { key: projectKey },
|
|
104
|
+
summary: name,
|
|
105
|
+
issuetype: /^\d+$/.test(type) ? { id: type } : { name: type },
|
|
106
|
+
description,
|
|
107
|
+
assignee: assignee
|
|
108
|
+
? client.apiVersion === "3"
|
|
109
|
+
? { accountId: assignee }
|
|
110
|
+
: { name: assignee }
|
|
111
|
+
: undefined,
|
|
112
|
+
...customFields,
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
}));
|
|
45
116
|
server.registerTool("move_issue_to_epic", {
|
|
46
117
|
title: "Move issue to epic",
|
|
47
|
-
description: 'Add
|
|
118
|
+
description: 'Add issues to an epic. On team-managed projects this sets the issue\'s parent instead. To remove an issue from an epic, use update_issue with fields { "parent": null } (team-managed) or { "epic": null } (company-managed).',
|
|
48
119
|
inputSchema: {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
epicKey: z.string().describe("Epic key, e.g. PROJ-1"),
|
|
120
|
+
epicIdOrKey: z.string().describe("Epic key, e.g. PROJ-1, or numeric issue ID"),
|
|
121
|
+
issueKeys: z.array(z.string()).min(1).describe("Issue keys to move, e.g. ['PROJ-2', 'PROJ-3']"),
|
|
52
122
|
},
|
|
53
|
-
}, async ({
|
|
123
|
+
}, async ({ epicIdOrKey, issueKeys }) => run(() => withNextGenFallback(() => client.agilePost(`/epic/${encodeURIComponent(epicIdOrKey)}/issue`, { issues: issueKeys }), async () => {
|
|
124
|
+
for (const issueKey of issueKeys) {
|
|
125
|
+
await client.apiPut(`/issue/${encodeURIComponent(issueKey)}`, {
|
|
126
|
+
fields: { parent: { key: epicIdOrKey } },
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return { moved: issueKeys, parent: epicIdOrKey };
|
|
130
|
+
})));
|
|
54
131
|
server.registerTool("get_epic_meta", {
|
|
55
132
|
title: "Get epic meta",
|
|
56
|
-
description: "
|
|
133
|
+
description: "List the epic-level issue types available in a project (hierarchy level above Story/Task), with the fields required to create one.",
|
|
57
134
|
inputSchema: {
|
|
58
|
-
projectKey: z.string().
|
|
135
|
+
projectKey: z.string().describe("Project key, e.g. PROJ"),
|
|
59
136
|
},
|
|
60
|
-
}, async ({ projectKey }) => run(() =>
|
|
137
|
+
}, async ({ projectKey }) => run(async () => {
|
|
138
|
+
const meta = (await client.apiGet(`/issue/createmeta/${encodeURIComponent(projectKey)}/issuetypes`));
|
|
139
|
+
const all = meta.issueTypes ?? [];
|
|
140
|
+
const epicTypes = all.filter((t) => (t.hierarchyLevel ?? 0) > 0 || /epic/i.test(t.name));
|
|
141
|
+
return {
|
|
142
|
+
projectKey,
|
|
143
|
+
epicIssueTypes: epicTypes,
|
|
144
|
+
allIssueTypes: all.map((t) => ({ id: t.id, name: t.name, hierarchyLevel: t.hierarchyLevel })),
|
|
145
|
+
hint: epicTypes.length > 0
|
|
146
|
+
? `Create one with create_epic(projectKey: "${projectKey}", issueType: "${epicTypes[0].name}", name: ...). Use get_issue_create_meta for the full required-field list.`
|
|
147
|
+
: "No epic-level issue type is available in this project.",
|
|
148
|
+
};
|
|
149
|
+
}));
|
|
61
150
|
}
|
package/dist/tools/issues.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { run } from "../util.js";
|
|
3
|
+
import { searchIssues } from "../search.js";
|
|
3
4
|
const DEFAULT_ISSUE_FIELDS = "summary,description,status,assignee,reporter,issuetype,labels,components,priority,created,updated,due,project";
|
|
4
5
|
export function registerIssueTools(server, client) {
|
|
5
6
|
server.registerTool("get_issue", {
|
|
@@ -65,20 +66,33 @@ export function registerIssueTools(server, client) {
|
|
|
65
66
|
}, async ({ issueKey }) => run(() => client.apiDelete(`/issue/${encodeURIComponent(issueKey)}`)));
|
|
66
67
|
server.registerTool("search_issues", {
|
|
67
68
|
title: "Search issues (JQL)",
|
|
68
|
-
description: "Search issues with JQL, e.g. 'project = PROJ AND sprint = 42 ORDER BY rank' or 'project = PROJ AND sprint IS NONE'.
|
|
69
|
+
description: "Search issues with JQL, e.g. 'project = PROJ AND sprint = 42 ORDER BY rank' or 'project = PROJ AND sprint IS NONE'. " +
|
|
70
|
+
"On Jira Cloud this uses enhanced search (/search/jql): the JQL must be bounded (include a restriction such as project, assignee, or key — a bare 'ORDER BY created DESC' is rejected), " +
|
|
71
|
+
"the response has no total, and paging is by cursor — pass the returned nextPageToken back in for the next page and stop when isLast is true. " +
|
|
72
|
+
"On Jira Data Center this uses the legacy /search endpoint, which pages with startAt and returns total.",
|
|
69
73
|
inputSchema: {
|
|
70
|
-
jql: z.string().describe("JQL query string"),
|
|
74
|
+
jql: z.string().describe("JQL query string (must be bounded on Jira Cloud)"),
|
|
71
75
|
fields: z.string().optional().describe(`Comma-separated fields (default: ${DEFAULT_ISSUE_FIELDS})`),
|
|
72
76
|
maxResults: z.number().int().min(1).max(100).default(25),
|
|
73
|
-
startAt: z.number().int().min(0).default(0),
|
|
77
|
+
startAt: z.number().int().min(0).default(0).describe("Offset paging, Jira Data Center only; rejected on Cloud, use nextPageToken"),
|
|
78
|
+
nextPageToken: z
|
|
79
|
+
.string()
|
|
80
|
+
.optional()
|
|
81
|
+
.describe("Jira Cloud only: cursor from the previous response's nextPageToken"),
|
|
74
82
|
expand: z.string().optional().describe("e.g. names, uris"),
|
|
83
|
+
includeApproximateTotal: z
|
|
84
|
+
.boolean()
|
|
85
|
+
.default(false)
|
|
86
|
+
.describe("Jira Cloud only: add an extra request for an approximate match count (approximateTotal)"),
|
|
75
87
|
},
|
|
76
|
-
}, async ({ jql, fields, maxResults, startAt, expand }) => run(() => client
|
|
88
|
+
}, async ({ jql, fields, maxResults, startAt, nextPageToken, expand, includeApproximateTotal }) => run(() => searchIssues(client, {
|
|
77
89
|
jql,
|
|
78
90
|
fields: fields ?? DEFAULT_ISSUE_FIELDS,
|
|
79
91
|
maxResults,
|
|
80
92
|
startAt,
|
|
93
|
+
nextPageToken,
|
|
81
94
|
expand,
|
|
95
|
+
includeApproximateTotal,
|
|
82
96
|
})));
|
|
83
97
|
server.registerTool("get_issue_create_meta", {
|
|
84
98
|
title: "Get issue create metadata",
|
package/dist/tools/projects.js
CHANGED
|
@@ -35,11 +35,18 @@ export function registerProjectTools(server, client) {
|
|
|
35
35
|
}, async ({ projectKey, name, description }) => run(() => client.apiPost(`/project/${encodeURIComponent(projectKey)}/components`, { name, description })));
|
|
36
36
|
server.registerTool("get_project_issue_types", {
|
|
37
37
|
title: "Get project issue types",
|
|
38
|
-
description: "List issue types available in a project.",
|
|
38
|
+
description: "List the issue types available in a project, with their hierarchy levels (epic = 1, story/task = 0, sub-task = -1).",
|
|
39
39
|
inputSchema: {
|
|
40
40
|
projectKey: z.string().describe("Project key"),
|
|
41
|
+
maxResults: z.number().int().min(1).max(1000).default(100),
|
|
42
|
+
startAt: z.number().int().min(0).default(0),
|
|
41
43
|
},
|
|
42
|
-
},
|
|
44
|
+
},
|
|
45
|
+
// /project/{key}/issuetypes does not exist; createmeta is the supported source.
|
|
46
|
+
async ({ projectKey, maxResults, startAt }) => run(() => client.apiGet(`/issue/createmeta/${encodeURIComponent(projectKey)}/issuetypes`, {
|
|
47
|
+
maxResults,
|
|
48
|
+
startAt,
|
|
49
|
+
})));
|
|
43
50
|
server.registerTool("get_project_roles", {
|
|
44
51
|
title: "Get project roles",
|
|
45
52
|
description: "List roles (and their actors) of a project.",
|
package/dist/tools/sprints.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
2
|
+
import { run } from "../util.js";
|
|
3
3
|
const sprintState = z.enum(["active", "closed", "future"]).describe("Sprint state filter");
|
|
4
4
|
export function registerSprintTools(server, client) {
|
|
5
5
|
server.registerTool("list_sprints", {
|
|
@@ -68,7 +68,7 @@ export function registerSprintTools(server, client) {
|
|
|
68
68
|
})));
|
|
69
69
|
server.registerTool("get_sprint_view", {
|
|
70
70
|
title: "Get sprint view",
|
|
71
|
-
description: "Full sprint view like the Jira UI: board
|
|
71
|
+
description: "Full sprint view like the Jira UI: board details, the sprint, and its issues, in one call.",
|
|
72
72
|
inputSchema: {
|
|
73
73
|
boardId: z.number().int().describe("Board ID"),
|
|
74
74
|
sprintId: z.number().int().describe("Sprint ID"),
|
|
@@ -76,15 +76,30 @@ export function registerSprintTools(server, client) {
|
|
|
76
76
|
.string()
|
|
77
77
|
.optional()
|
|
78
78
|
.describe("Comma-separated issue fields to return (default: key, summary, status, assignee)"),
|
|
79
|
+
maxResults: z.number().int().min(1).max(1000).default(100),
|
|
80
|
+
startAt: z.number().int().min(0).default(0),
|
|
79
81
|
},
|
|
80
|
-
}, async ({ boardId, sprintId, fields }) => run(() =>
|
|
81
|
-
|
|
82
|
-
|
|
82
|
+
}, async ({ boardId, sprintId, fields, maxResults, startAt }) => run(async () => {
|
|
83
|
+
// Composed from three documented Agile endpoints; the old /rest/agile/1.0/rapid
|
|
84
|
+
// path this used to call is not a real endpoint and answered 404.
|
|
85
|
+
const [board, sprint, issues] = await Promise.all([
|
|
86
|
+
client.agileGet(`/board/${boardId}`),
|
|
87
|
+
client.agileGet(`/sprint/${sprintId}`),
|
|
88
|
+
client.agileGet(`/sprint/${sprintId}/issue`, {
|
|
89
|
+
fields: fields ?? "key,summary,status,assignee",
|
|
90
|
+
maxResults,
|
|
91
|
+
startAt,
|
|
92
|
+
}),
|
|
93
|
+
]);
|
|
94
|
+
return { board, sprint, issues };
|
|
95
|
+
}));
|
|
83
96
|
server.registerTool("get_backlog", {
|
|
84
97
|
title: "Get board backlog",
|
|
85
|
-
description: "List
|
|
98
|
+
description: "List a board's backlog issues (in the board's filter, not in a sprint), ordered by rank. " +
|
|
99
|
+
"Uses the Agile API's own backlog endpoint, so it pages with startAt/total on both Cloud and Data Center.",
|
|
86
100
|
inputSchema: {
|
|
87
101
|
boardId: z.number().int().describe("Board ID"),
|
|
102
|
+
jql: z.string().optional().describe("Extra JQL to narrow the backlog, e.g. 'assignee IS EMPTY'"),
|
|
88
103
|
fields: z
|
|
89
104
|
.string()
|
|
90
105
|
.optional()
|
|
@@ -92,15 +107,10 @@ export function registerSprintTools(server, client) {
|
|
|
92
107
|
maxResults: z.number().int().min(1).max(100).default(50),
|
|
93
108
|
startAt: z.number().int().min(0).default(0),
|
|
94
109
|
},
|
|
95
|
-
}, async ({ boardId, fields, maxResults, startAt }) => run(
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
fields: fields ?? "key,summary,status,assignee",
|
|
102
|
-
maxResults,
|
|
103
|
-
startAt,
|
|
104
|
-
});
|
|
105
|
-
}));
|
|
110
|
+
}, async ({ boardId, jql, fields, maxResults, startAt }) => run(() => client.agileGet(`/board/${boardId}/backlog`, {
|
|
111
|
+
jql,
|
|
112
|
+
fields: fields ?? "key,summary,status,assignee",
|
|
113
|
+
maxResults,
|
|
114
|
+
startAt,
|
|
115
|
+
})));
|
|
106
116
|
}
|
package/dist/util.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thammarongg/jira-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "MCP server exposing the Jira REST API (boards, sprints, issues, JQL, and a generic passthrough) for Jira Cloud and Data Center",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"claude"
|
|
26
26
|
],
|
|
27
27
|
"scripts": {
|
|
28
|
-
"build": "tsc",
|
|
28
|
+
"build": "tsc && node -e \"require('fs').chmodSync('dist/index.js', 0o755)\"",
|
|
29
29
|
"typecheck": "tsc --noEmit",
|
|
30
30
|
"start": "node dist/index.js",
|
|
31
31
|
"dev": "tsx src/index.ts",
|
package/skill/SKILL.md
CHANGED
|
@@ -67,8 +67,9 @@ After setup, verify with the `get_current_user` tool.
|
|
|
67
67
|
- **Boards/sprints**: `list_boards`, `get_board`, `list_sprints`, `get_sprint`,
|
|
68
68
|
`create_sprint`, `update_sprint`, `close_sprint`, `get_sprint_issues`,
|
|
69
69
|
`get_sprint_view` (UI-like full view), `get_backlog`
|
|
70
|
-
- **Epics**: `list_epics
|
|
71
|
-
`move_issue_to_epic`, `get_epic_meta`
|
|
70
|
+
- **Epics**: `list_epics` (by board), `get_epic`, `get_epic_issues`,
|
|
71
|
+
`create_epic`, `move_issue_to_epic`, `get_epic_meta` — epics are addressed by
|
|
72
|
+
key or numeric ID, and these work on team-managed projects too
|
|
72
73
|
- **Issues**: `get_issue`, `create_issue`, `update_issue`, `delete_issue`,
|
|
73
74
|
`search_issues` (JQL), `get_issue_create_meta`, `get_issue_transitions`,
|
|
74
75
|
`transition_issue`, `assign_issue`, `add_comment`, `list_comments`,
|
|
@@ -84,8 +85,13 @@ After setup, verify with the `get_current_user` tool.
|
|
|
84
85
|
**Sprint status**: `list_boards` → pick board → `list_sprints` (state
|
|
85
86
|
`active`) → `get_sprint_issues` or `get_sprint_view` for the full picture.
|
|
86
87
|
|
|
87
|
-
**Backlog review**: `get_backlog(boardId)` — issues
|
|
88
|
-
|
|
88
|
+
**Backlog review**: `get_backlog(boardId)` — issues on the board that are not
|
|
89
|
+
in a sprint, ordered by rank; pass `jql` to narrow it.
|
|
90
|
+
|
|
91
|
+
**Epic breakdown**: `list_epics(boardId)` → `get_epic_issues(epicIdOrKey)` for
|
|
92
|
+
the children. To create one, `get_epic_meta(projectKey)` gives the epic issue
|
|
93
|
+
type and then `create_epic`; company-managed projects also need the "Epic Name"
|
|
94
|
+
custom field via `customFields`.
|
|
89
95
|
|
|
90
96
|
**Create an issue**: call `get_issue_create_meta(projectKeys=...)` first to
|
|
91
97
|
discover valid issue types and required fields, then `create_issue`
|
|
@@ -94,7 +100,9 @@ discover valid issue types and required fields, then `create_issue`
|
|
|
94
100
|
**Move work through the workflow**: `get_issue_transitions(issueKey)` to see
|
|
95
101
|
available transitions and required fields, then `transition_issue`.
|
|
96
102
|
|
|
97
|
-
**Search**: `search_issues` with JQL.
|
|
103
|
+
**Search**: `search_issues` with JQL. On Jira Cloud the query must be
|
|
104
|
+
**bounded** — always include a restriction such as `project`, `assignee`, or
|
|
105
|
+
`key`; a bare `ORDER BY created DESC` is rejected. Common queries:
|
|
98
106
|
- Current sprint: `project = PROJ AND sprint = <sprintId> ORDER BY rank`
|
|
99
107
|
- Unassigned in project: `project = PROJ AND assignee IS EMPTY`
|
|
100
108
|
- Due this week: `project = PROJ AND duedate <= endOfWeek() ORDER BY duedate`
|
|
@@ -104,6 +112,12 @@ available transitions and required fields, then `transition_issue`.
|
|
|
104
112
|
|
|
105
113
|
- List tools paginate with `startAt`/`maxResults`; responses include `total` —
|
|
106
114
|
page with `startAt` when `total` exceeds the page size.
|
|
115
|
+
- `search_issues` is the exception on **Jira Cloud**: it uses Atlassian's
|
|
116
|
+
enhanced search (`/search/jql`, which replaced the removed `/search`). There
|
|
117
|
+
is no `total`; page by passing the response's `nextPageToken` back in and
|
|
118
|
+
stop when `isLast` is true — `startAt` is rejected. Pass
|
|
119
|
+
`includeApproximateTotal: true` for a rough match count (one extra request).
|
|
120
|
+
On Data Center it still uses `startAt`/`total`.
|
|
107
121
|
- Assignees: pass an account ID on Cloud, a username on DC — the server maps
|
|
108
122
|
it to the right field shape automatically.
|
|
109
123
|
- To remove an issue from an epic: `update_issue` with
|