@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 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 (rapid view: board + sprint + issues) |
176
- | `get_backlog` | Board backlog via JQL (`sprint IS NONE ORDER BY rank`) |
175
+ | `get_sprint_view` | Full UI-like sprint view (board + sprint + issues in one call) |
176
+ | `get_backlog` | Board backlog (Agile `/board/{id}/backlog`, rank-ordered) |
177
177
 
178
178
  ### Epics
179
179
 
180
180
  | Tool | Description |
181
181
  | --- | --- |
182
- | `list_epics` / `get_epic` / `get_epic_issues` | Read epics |
183
- | `create_epic` | New epic on a board |
184
- | `move_issue_to_epic` | Add an issue to an epic |
185
- | `get_epic_meta` | Epic issue-type metadata |
182
+ | `list_epics` | Epics on a board (optionally filtered by `done`) |
183
+ | `get_epic` / `get_epic_issues` | Read an epic and its children (works on team-managed projects) |
184
+ | `create_epic` | New epic in a project |
185
+ | `move_issue_to_epic` | Add issues to an epic (sets `parent` on team-managed) |
186
+ | `get_epic_meta` | Epic-level issue types available in a project |
186
187
 
187
188
  ### Issues
188
189
 
@@ -192,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** with pagination |
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
- - `get_backlog` is implemented via JQL since the Agile API has no direct backlog endpoint.
234
+ - Pagination: most list tools return Jira's native `startAt`/`maxResults`/`total`; pass `startAt` to page.
235
+ - JQL search on **Jira Cloud** uses `/rest/api/3/search/jql`, since Atlassian removed `GET /rest/api/{2,3}/search` on 2025-05-01 ([CHANGE-2046](https://developer.atlassian.com/changelog/#CHANGE-2046) — the old endpoint now returns HTTP 410). Consequences for `search_issues` on Cloud: the JQL must be **bounded** (include a restriction such as `project`, `assignee`, or `key`), the response carries **no `total`**, and paging is by cursor — pass the returned `nextPageToken` back and stop when `isLast` is true. `startAt` is rejected there rather than silently ignored, and `includeApproximateTotal: true` adds an approximate match count via `/search/approximate-count`.
236
+ - Jira **Data Center** keeps the legacy `/search` endpoint with `startAt`/`total`; if a Cloud site on a custom domain is misdetected as DC, a 410 from `/search` transparently retries against `/search/jql`.
237
+ - `get_backlog` calls the Agile API's `/board/{id}/backlog` endpoint, so it keeps `startAt`/`total` paging on Cloud and DC alike; pass `jql` to narrow it further.
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.1.0" });
22
+ const server = new McpServer({ name: "jira", version: "0.3.0" });
23
23
  registerBoardTools(server, client);
24
24
  registerSprintTools(server, client);
25
25
  registerEpicTools(server, client);
package/dist/install.js CHANGED
@@ -223,10 +223,16 @@ function askPassword(p, promptText) {
223
223
  finish("");
224
224
  return;
225
225
  }
226
- if (ch === "\u007f" || ch === "\b")
227
- value = value.slice(0, -1);
228
- else if (ch >= " ")
226
+ if (ch === "\u007f" || ch === "\b") {
227
+ if (value.length > 0) {
228
+ value = value.slice(0, -1);
229
+ process.stdout.write("\b \b");
230
+ }
231
+ }
232
+ else if (ch >= " ") {
229
233
  value += ch;
234
+ process.stdout.write("*");
235
+ }
230
236
  }
231
237
  };
232
238
  const onEnd = () => finish(null);
@@ -344,7 +350,7 @@ async function promptCredentials(p) {
344
350
  const user = (await p.ask(isCloud ? "Atlassian email: " : "Data Center username: ")).trim();
345
351
  if (!user)
346
352
  throw new Error(isCloud ? "Email is required" : "Username is required");
347
- const secret = await askPassword(p, isCloud ? "API token (hidden, from id.atlassian.com): " : "API token / app password (hidden): ");
353
+ const secret = await askPassword(p, isCloud ? "API token (masked, from id.atlassian.com): " : "API token / app password (masked): ");
348
354
  if (!secret)
349
355
  throw new Error("API token is required");
350
356
  const env = { JIRA_BASE_URL: baseUrl };
package/dist/search.js ADDED
@@ -0,0 +1,56 @@
1
+ import { JiraApiError } from "./client.js";
2
+ /**
3
+ * Jira Cloud removed GET /rest/api/{2,3}/search on 2025-05-01 (CHANGE-2046); it now
4
+ * answers HTTP 410. The replacement, /search/jql ("enhanced search"), differs in three
5
+ * ways that matter to callers: it pages with an opaque nextPageToken instead of startAt,
6
+ * it returns no total, and it rejects unbounded JQL. Jira Data Center still only has the
7
+ * legacy endpoint, so both live here behind one call.
8
+ */
9
+ export class UnsupportedParameterError extends Error {
10
+ constructor(message) {
11
+ super(message);
12
+ this.name = "UnsupportedParameter";
13
+ }
14
+ }
15
+ export async function searchIssues(client, params) {
16
+ if (!client.isCloud) {
17
+ try {
18
+ return await legacySearch(client, params);
19
+ }
20
+ catch (err) {
21
+ // A Cloud site on a custom domain looks like Data Center to loadConfig, so let the
22
+ // removal response itself route us to the new endpoint.
23
+ if (!(err instanceof JiraApiError && err.status === 410))
24
+ throw err;
25
+ }
26
+ }
27
+ return enhancedSearch(client, params);
28
+ }
29
+ function legacySearch(client, params) {
30
+ return client.apiGet("/search", {
31
+ jql: params.jql,
32
+ fields: params.fields,
33
+ maxResults: params.maxResults,
34
+ startAt: params.startAt ?? 0,
35
+ expand: params.expand,
36
+ });
37
+ }
38
+ async function enhancedSearch(client, params) {
39
+ if (params.startAt !== undefined && params.startAt > 0) {
40
+ // /search/jql accepts startAt and silently ignores it, which would hand back page 1
41
+ // forever. Refusing is better than looping over the same issues.
42
+ throw new UnsupportedParameterError("Jira Cloud's enhanced search (/search/jql) pages with a cursor, not an offset: startAt is ignored. " +
43
+ "Omit startAt and pass nextPageToken from the previous response to get the next page.");
44
+ }
45
+ const page = await client.apiGet("/search/jql", {
46
+ jql: params.jql,
47
+ fields: params.fields,
48
+ maxResults: params.maxResults,
49
+ nextPageToken: params.nextPageToken,
50
+ expand: params.expand,
51
+ });
52
+ if (!params.includeApproximateTotal || !page || typeof page !== "object")
53
+ return page;
54
+ const counted = (await client.apiPost("/search/approximate-count", { jql: params.jql }));
55
+ return { ...page, approximateTotal: counted?.count };
56
+ }
@@ -1,61 +1,150 @@
1
1
  import { z } from "zod";
2
- import { rapidViewId, run } from "../util.js";
2
+ import { JiraApiError } from "../client.js";
3
+ import { run } from "../util.js";
4
+ import { searchIssues } from "../search.js";
5
+ const DEFAULT_EPIC_ISSUE_FIELDS = "key,summary,status,assignee";
6
+ /**
7
+ * The Agile epic endpoints (/rest/agile/1.0/epic/...) only understand classic
8
+ * (company-managed) epics: on a team-managed ("next-gen") project they answer
9
+ * HTTP 400 "The request contains a next-gen issue". There, an epic is an ordinary
10
+ * issue and its children are linked by `parent`, so each tool falls back to the
11
+ * plain issue/search APIs when the Agile call rejects the project style.
12
+ */
13
+ function isNextGenRejection(err) {
14
+ return err instanceof JiraApiError && (err.status === 400 || err.status === 404);
15
+ }
16
+ async function withNextGenFallback(agile, fallback) {
17
+ try {
18
+ return await agile();
19
+ }
20
+ catch (err) {
21
+ if (!isNextGenRejection(err))
22
+ throw err;
23
+ return fallback();
24
+ }
25
+ }
3
26
  export function registerEpicTools(server, client) {
4
27
  server.registerTool("list_epics", {
5
28
  title: "List epics",
6
- description: "List epics visible in a board's sprint view (agile rapid view).",
29
+ description: "List the epics on a board, including whether each one is done.",
7
30
  inputSchema: {
8
31
  boardId: z.number().int().describe("Board ID"),
9
- sprintId: z.number().int().describe("Sprint ID (any sprint on the board works)"),
32
+ done: z.boolean().optional().describe("Filter by completion state; omit for all epics"),
10
33
  maxResults: z.number().int().min(1).max(1000).default(100),
11
34
  startAt: z.number().int().min(0).default(0),
12
35
  },
13
- }, async ({ boardId, sprintId, maxResults, startAt }) => run(() => client.agileGet(`/rapid/${rapidViewId(boardId, sprintId)}/epic`, { maxResults, startAt })));
36
+ }, async ({ boardId, done, maxResults, startAt }) => run(() => client.agileGet(`/board/${boardId}/epic`, {
37
+ done: done === undefined ? undefined : String(done),
38
+ maxResults,
39
+ startAt,
40
+ })));
14
41
  server.registerTool("get_epic", {
15
42
  title: "Get epic",
16
- description: "Get a single epic by its issue ID (numeric).",
43
+ description: "Get a single epic by key (e.g. PROJ-1) or numeric issue ID. Falls back to the issue API for team-managed projects, where epics are ordinary issues.",
17
44
  inputSchema: {
18
- epicId: z.number().int().describe("Epic issue ID (numeric, not the key)"),
45
+ epicIdOrKey: z.string().describe("Epic key, e.g. PROJ-1, or numeric issue ID"),
19
46
  },
20
- }, async ({ epicId }) => run(() => client.agileGet(`/epic/${epicId}`)));
47
+ }, async ({ epicIdOrKey }) => run(() => {
48
+ const id = encodeURIComponent(epicIdOrKey);
49
+ return withNextGenFallback(() => client.agileGet(`/epic/${id}`), () => client.apiGet(`/issue/${id}`, { fields: "summary,status,assignee,issuetype,project,description" }));
50
+ }));
21
51
  server.registerTool("get_epic_issues", {
22
52
  title: "Get epic issues",
23
- description: "List the issues belonging to an epic.",
53
+ description: "List the issues belonging to an epic. Falls back to a JQL 'parent = <epic>' search on team-managed projects, where the Agile epic API does not apply.",
24
54
  inputSchema: {
25
- epicId: z.number().int().describe("Epic issue ID (numeric)"),
26
- maxResults: z.number().int().min(1).max(1000).default(100),
55
+ epicIdOrKey: z.string().describe("Epic key, e.g. PROJ-1, or numeric issue ID"),
56
+ fields: z
57
+ .string()
58
+ .optional()
59
+ .describe(`Comma-separated issue fields (default: ${DEFAULT_EPIC_ISSUE_FIELDS})`),
60
+ maxResults: z.number().int().min(1).max(100).default(100),
27
61
  startAt: z.number().int().min(0).default(0),
62
+ nextPageToken: z
63
+ .string()
64
+ .optional()
65
+ .describe("Jira Cloud only: cursor returned by a previous fallback search"),
28
66
  },
29
- }, async ({ epicId, maxResults, startAt }) => run(() => client.agileGet(`/epic/${epicId}/issue`, { maxResults, startAt })));
67
+ }, async ({ epicIdOrKey, fields, maxResults, startAt, nextPageToken }) => run(() => {
68
+ const issueFields = fields ?? DEFAULT_EPIC_ISSUE_FIELDS;
69
+ return withNextGenFallback(() => client.agileGet(`/epic/${encodeURIComponent(epicIdOrKey)}/issue`, {
70
+ fields: issueFields,
71
+ maxResults,
72
+ startAt,
73
+ }), () => searchIssues(client, {
74
+ // JQL `parent` accepts a key or a numeric issue ID.
75
+ jql: `parent = ${epicIdOrKey} ORDER BY rank`,
76
+ fields: issueFields,
77
+ maxResults,
78
+ startAt,
79
+ nextPageToken,
80
+ }));
81
+ }));
30
82
  server.registerTool("create_epic", {
31
83
  title: "Create epic",
32
- description: "Create a new epic on a board (agile rapid view).",
84
+ description: "Create an epic in a project. Company-managed projects usually also require the 'Epic Name' custom field — call get_epic_meta or get_issue_create_meta first and pass it via customFields.",
33
85
  inputSchema: {
34
- boardId: z.number().int().describe("Board ID"),
35
- sprintId: z.number().int().describe("Sprint ID (any sprint on the board works)"),
36
- name: z.string().describe("Epic name"),
86
+ projectKey: z.string().describe("Project key, e.g. PROJ"),
87
+ name: z.string().describe("Epic name (used as the issue summary)"),
37
88
  description: z.string().optional(),
38
- lead: z.string().optional().describe("Epic lead (user account ID or username)"),
89
+ issueType: z
90
+ .string()
91
+ .optional()
92
+ .describe("Epic issue type name or ID (default: Epic); see get_epic_meta"),
93
+ assignee: z.string().optional().describe("Account ID on Cloud/v3, username on DC/v2"),
94
+ customFields: z
95
+ .record(z.string(), z.unknown())
96
+ .optional()
97
+ .describe("Extra fields, e.g. { 'customfield_10011': 'Epic name' } for company-managed projects"),
39
98
  },
40
- }, async ({ boardId, sprintId, name, description, lead }) => run(() => client.agilePost(`/rapid/${rapidViewId(boardId, sprintId)}/epic`, {
41
- name,
42
- description,
43
- lead,
44
- })));
99
+ }, async ({ projectKey, name, description, issueType, assignee, customFields }) => run(() => {
100
+ const type = issueType ?? "Epic";
101
+ return client.apiPost("/issue", {
102
+ fields: {
103
+ project: { key: projectKey },
104
+ summary: name,
105
+ issuetype: /^\d+$/.test(type) ? { id: type } : { name: type },
106
+ description,
107
+ assignee: assignee
108
+ ? client.apiVersion === "3"
109
+ ? { accountId: assignee }
110
+ : { name: assignee }
111
+ : undefined,
112
+ ...customFields,
113
+ },
114
+ });
115
+ }));
45
116
  server.registerTool("move_issue_to_epic", {
46
117
  title: "Move issue to epic",
47
- description: 'Add an issue to an epic. To remove an issue from an epic, use `update_issue` with fields `{ "epic": null }`.',
118
+ description: 'Add issues to an epic. On team-managed projects this sets the issue\'s parent instead. To remove an issue from an epic, use update_issue with fields { "parent": null } (team-managed) or { "epic": null } (company-managed).',
48
119
  inputSchema: {
49
- epicId: z.number().int().describe("Epic issue ID (numeric)"),
50
- issueId: z.number().int().describe("Issue ID to move (numeric)"),
51
- epicKey: z.string().describe("Epic key, e.g. PROJ-1"),
120
+ epicIdOrKey: z.string().describe("Epic key, e.g. PROJ-1, or numeric issue ID"),
121
+ issueKeys: z.array(z.string()).min(1).describe("Issue keys to move, e.g. ['PROJ-2', 'PROJ-3']"),
52
122
  },
53
- }, async ({ epicId, issueId, epicKey }) => run(() => client.agilePut(`/epic/${epicId}/issue/${issueId}`, { epic: { key: epicKey } })));
123
+ }, async ({ epicIdOrKey, issueKeys }) => run(() => withNextGenFallback(() => client.agilePost(`/epic/${encodeURIComponent(epicIdOrKey)}/issue`, { issues: issueKeys }), async () => {
124
+ for (const issueKey of issueKeys) {
125
+ await client.apiPut(`/issue/${encodeURIComponent(issueKey)}`, {
126
+ fields: { parent: { key: epicIdOrKey } },
127
+ });
128
+ }
129
+ return { moved: issueKeys, parent: epicIdOrKey };
130
+ })));
54
131
  server.registerTool("get_epic_meta", {
55
132
  title: "Get epic meta",
56
- description: "Get epic metadata (available epic issue types) for a project.",
133
+ description: "List the epic-level issue types available in a project (hierarchy level above Story/Task), with the fields required to create one.",
57
134
  inputSchema: {
58
- projectKey: z.string().optional().describe("Project key filter"),
135
+ projectKey: z.string().describe("Project key, e.g. PROJ"),
59
136
  },
60
- }, async ({ projectKey }) => run(() => client.agileGet("/epic/meta", { projectKey })));
137
+ }, async ({ projectKey }) => run(async () => {
138
+ const meta = (await client.apiGet(`/issue/createmeta/${encodeURIComponent(projectKey)}/issuetypes`));
139
+ const all = meta.issueTypes ?? [];
140
+ const epicTypes = all.filter((t) => (t.hierarchyLevel ?? 0) > 0 || /epic/i.test(t.name));
141
+ return {
142
+ projectKey,
143
+ epicIssueTypes: epicTypes,
144
+ allIssueTypes: all.map((t) => ({ id: t.id, name: t.name, hierarchyLevel: t.hierarchyLevel })),
145
+ hint: epicTypes.length > 0
146
+ ? `Create one with create_epic(projectKey: "${projectKey}", issueType: "${epicTypes[0].name}", name: ...). Use get_issue_create_meta for the full required-field list.`
147
+ : "No epic-level issue type is available in this project.",
148
+ };
149
+ }));
61
150
  }
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { run } from "../util.js";
3
+ import { searchIssues } from "../search.js";
3
4
  const DEFAULT_ISSUE_FIELDS = "summary,description,status,assignee,reporter,issuetype,labels,components,priority,created,updated,due,project";
4
5
  export function registerIssueTools(server, client) {
5
6
  server.registerTool("get_issue", {
@@ -65,20 +66,33 @@ export function registerIssueTools(server, client) {
65
66
  }, async ({ issueKey }) => run(() => client.apiDelete(`/issue/${encodeURIComponent(issueKey)}`)));
66
67
  server.registerTool("search_issues", {
67
68
  title: "Search issues (JQL)",
68
- description: "Search issues with JQL, e.g. 'project = PROJ AND sprint = 42 ORDER BY rank' or 'project = PROJ AND sprint IS NONE'. Returns issues plus total count and pagination info.",
69
+ description: "Search issues with JQL, e.g. 'project = PROJ AND sprint = 42 ORDER BY rank' or 'project = PROJ AND sprint IS NONE'. " +
70
+ "On Jira Cloud this uses enhanced search (/search/jql): the JQL must be bounded (include a restriction such as project, assignee, or key — a bare 'ORDER BY created DESC' is rejected), " +
71
+ "the response has no total, and paging is by cursor — pass the returned nextPageToken back in for the next page and stop when isLast is true. " +
72
+ "On Jira Data Center this uses the legacy /search endpoint, which pages with startAt and returns total.",
69
73
  inputSchema: {
70
- jql: z.string().describe("JQL query string"),
74
+ jql: z.string().describe("JQL query string (must be bounded on Jira Cloud)"),
71
75
  fields: z.string().optional().describe(`Comma-separated fields (default: ${DEFAULT_ISSUE_FIELDS})`),
72
76
  maxResults: z.number().int().min(1).max(100).default(25),
73
- startAt: z.number().int().min(0).default(0),
77
+ startAt: z.number().int().min(0).default(0).describe("Offset paging, Jira Data Center only; rejected on Cloud, use nextPageToken"),
78
+ nextPageToken: z
79
+ .string()
80
+ .optional()
81
+ .describe("Jira Cloud only: cursor from the previous response's nextPageToken"),
74
82
  expand: z.string().optional().describe("e.g. names, uris"),
83
+ includeApproximateTotal: z
84
+ .boolean()
85
+ .default(false)
86
+ .describe("Jira Cloud only: add an extra request for an approximate match count (approximateTotal)"),
75
87
  },
76
- }, async ({ jql, fields, maxResults, startAt, expand }) => run(() => client.apiGet("/search", {
88
+ }, async ({ jql, fields, maxResults, startAt, nextPageToken, expand, includeApproximateTotal }) => run(() => searchIssues(client, {
77
89
  jql,
78
90
  fields: fields ?? DEFAULT_ISSUE_FIELDS,
79
91
  maxResults,
80
92
  startAt,
93
+ nextPageToken,
81
94
  expand,
95
+ includeApproximateTotal,
82
96
  })));
83
97
  server.registerTool("get_issue_create_meta", {
84
98
  title: "Get issue create metadata",
@@ -35,11 +35,18 @@ export function registerProjectTools(server, client) {
35
35
  }, async ({ projectKey, name, description }) => run(() => client.apiPost(`/project/${encodeURIComponent(projectKey)}/components`, { name, description })));
36
36
  server.registerTool("get_project_issue_types", {
37
37
  title: "Get project issue types",
38
- description: "List issue types available in a project.",
38
+ description: "List the issue types available in a project, with their hierarchy levels (epic = 1, story/task = 0, sub-task = -1).",
39
39
  inputSchema: {
40
40
  projectKey: z.string().describe("Project key"),
41
+ maxResults: z.number().int().min(1).max(1000).default(100),
42
+ startAt: z.number().int().min(0).default(0),
41
43
  },
42
- }, async ({ projectKey }) => run(() => client.apiGet(`/project/${encodeURIComponent(projectKey)}/issuetypes`)));
44
+ },
45
+ // /project/{key}/issuetypes does not exist; createmeta is the supported source.
46
+ async ({ projectKey, maxResults, startAt }) => run(() => client.apiGet(`/issue/createmeta/${encodeURIComponent(projectKey)}/issuetypes`, {
47
+ maxResults,
48
+ startAt,
49
+ })));
43
50
  server.registerTool("get_project_roles", {
44
51
  title: "Get project roles",
45
52
  description: "List roles (and their actors) of a project.",
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { rapidViewId, run } from "../util.js";
2
+ import { run } from "../util.js";
3
3
  const sprintState = z.enum(["active", "closed", "future"]).describe("Sprint state filter");
4
4
  export function registerSprintTools(server, client) {
5
5
  server.registerTool("list_sprints", {
@@ -68,7 +68,7 @@ export function registerSprintTools(server, client) {
68
68
  })));
69
69
  server.registerTool("get_sprint_view", {
70
70
  title: "Get sprint view",
71
- description: "Full sprint view like the Jira UI: board info, current sprint, and all its issues. Uses the agile rapid view API (rapidViewId = boardId * 10^13 + sprintId).",
71
+ description: "Full sprint view like the Jira UI: board details, the sprint, and its issues, in one call.",
72
72
  inputSchema: {
73
73
  boardId: z.number().int().describe("Board ID"),
74
74
  sprintId: z.number().int().describe("Sprint ID"),
@@ -76,15 +76,30 @@ export function registerSprintTools(server, client) {
76
76
  .string()
77
77
  .optional()
78
78
  .describe("Comma-separated issue fields to return (default: key, summary, status, assignee)"),
79
+ maxResults: z.number().int().min(1).max(1000).default(100),
80
+ startAt: z.number().int().min(0).default(0),
79
81
  },
80
- }, async ({ boardId, sprintId, fields }) => run(() => client.agileGet(`/rapid/${rapidViewId(boardId, sprintId)}`, {
81
- fields: fields ?? "key,summary,status,assignee",
82
- })));
82
+ }, async ({ boardId, sprintId, fields, maxResults, startAt }) => run(async () => {
83
+ // Composed from three documented Agile endpoints; the old /rest/agile/1.0/rapid
84
+ // path this used to call is not a real endpoint and answered 404.
85
+ const [board, sprint, issues] = await Promise.all([
86
+ client.agileGet(`/board/${boardId}`),
87
+ client.agileGet(`/sprint/${sprintId}`),
88
+ client.agileGet(`/sprint/${sprintId}/issue`, {
89
+ fields: fields ?? "key,summary,status,assignee",
90
+ maxResults,
91
+ startAt,
92
+ }),
93
+ ]);
94
+ return { board, sprint, issues };
95
+ }));
83
96
  server.registerTool("get_backlog", {
84
97
  title: "Get board backlog",
85
- description: "List backlog issues for a board (issues in the board's projects with no sprint), ordered by rank. Implemented via JQL: project in (<board projects>) AND sprint IS NONE ORDER BY rank.",
98
+ description: "List a board's backlog issues (in the board's filter, not in a sprint), ordered by rank. " +
99
+ "Uses the Agile API's own backlog endpoint, so it pages with startAt/total on both Cloud and Data Center.",
86
100
  inputSchema: {
87
101
  boardId: z.number().int().describe("Board ID"),
102
+ jql: z.string().optional().describe("Extra JQL to narrow the backlog, e.g. 'assignee IS EMPTY'"),
88
103
  fields: z
89
104
  .string()
90
105
  .optional()
@@ -92,15 +107,10 @@ export function registerSprintTools(server, client) {
92
107
  maxResults: z.number().int().min(1).max(100).default(50),
93
108
  startAt: z.number().int().min(0).default(0),
94
109
  },
95
- }, async ({ boardId, fields, maxResults, startAt }) => run(async () => {
96
- const board = (await client.agileGet(`/board/${boardId}`));
97
- const keys = (board.projects ?? []).map((p) => p.key);
98
- const jql = keys.length > 0 ? `project in (${keys.join(", ")}) AND sprint IS NONE ORDER BY rank` : "sprint IS NONE ORDER BY rank";
99
- return client.apiGet("/search", {
100
- jql,
101
- fields: fields ?? "key,summary,status,assignee",
102
- maxResults,
103
- startAt,
104
- });
105
- }));
110
+ }, async ({ boardId, jql, fields, maxResults, startAt }) => run(() => client.agileGet(`/board/${boardId}/backlog`, {
111
+ jql,
112
+ fields: fields ?? "key,summary,status,assignee",
113
+ maxResults,
114
+ startAt,
115
+ })));
106
116
  }
package/dist/util.js CHANGED
@@ -19,6 +19,3 @@ export async function run(fn) {
19
19
  };
20
20
  }
21
21
  }
22
- export function rapidViewId(boardId, sprintId) {
23
- return boardId * 10_000_000_000_000 + sprintId;
24
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thammarongg/jira-mcp",
3
- "version": "0.2.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`, `get_epic`, `get_epic_issues`, `create_epic`,
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 in the board's projects
88
- with no sprint, ordered by rank.
88
+ **Backlog review**: `get_backlog(boardId)` — issues on the board that are not
89
+ in a sprint, ordered by rank; pass `jql` to narrow it.
90
+
91
+ **Epic breakdown**: `list_epics(boardId)` → `get_epic_issues(epicIdOrKey)` for
92
+ the children. To create one, `get_epic_meta(projectKey)` gives the epic issue
93
+ type and then `create_epic`; company-managed projects also need the "Epic Name"
94
+ custom field via `customFields`.
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. Common queries:
103
+ **Search**: `search_issues` with JQL. On Jira Cloud the query must be
104
+ **bounded** — always include a restriction such as `project`, `assignee`, or
105
+ `key`; a bare `ORDER BY created DESC` is rejected. Common queries:
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