@rui.branco/jira-mcp 1.7.12 → 1.7.14
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 +39 -0
- package/assets/icon.svg +1 -0
- package/icons.js +22 -0
- package/index.js +167 -33
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -136,6 +136,8 @@ Use `@FirstName LastName` syntax to mention users in comments:
|
|
|
136
136
|
# The mention is automatically resolved and the user gets notified
|
|
137
137
|
```
|
|
138
138
|
|
|
139
|
+
Mentions are resolved with Jira's global user search first. On instances where your account lacks the global "Browse users and groups" permission (the search comes back empty), the name is looked up among users assignable to the ticket being commented on (`/user/assignable/search?issueKey=...`), or, when no ticket is known, among users assignable in any of the instance's configured `projects` (one `/user/assignable/search?project=...` request per project, results merged and deduplicated; a project you can't access is skipped). A name that still can't be resolved is posted as plain text, which notifies nobody. `jira_search_users` uses the same configured-projects fallback when you don't pass `issueKey` or `projectKey`.
|
|
140
|
+
|
|
139
141
|
### Example Output
|
|
140
142
|
|
|
141
143
|
```
|
|
@@ -250,6 +252,43 @@ Config stored at `~/.config/jira-mcp/config.json`:
|
|
|
250
252
|
}
|
|
251
253
|
```
|
|
252
254
|
|
|
255
|
+
#### Multiple instances
|
|
256
|
+
|
|
257
|
+
```json
|
|
258
|
+
{
|
|
259
|
+
"instances": [
|
|
260
|
+
{
|
|
261
|
+
"name": "work",
|
|
262
|
+
"email": "you@company.com",
|
|
263
|
+
"token": "...",
|
|
264
|
+
"baseUrl": "https://company.atlassian.net",
|
|
265
|
+
"projects": ["PROJ", "ENG"]
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
"name": "client",
|
|
269
|
+
"email": "you@company.com",
|
|
270
|
+
"token": "...",
|
|
271
|
+
"baseUrl": "https://client.atlassian.net",
|
|
272
|
+
"projects": ["CLI"],
|
|
273
|
+
"paths": ["~/code/client-app", "/Users/you/code/client-api"]
|
|
274
|
+
}
|
|
275
|
+
],
|
|
276
|
+
"defaultInstance": "work"
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
- `projects`: project key prefixes routed to the instance. Tools that receive an issue or project key (e.g. `CLI-12`) are routed to the instance that owns the prefix.
|
|
281
|
+
- `paths` (optional): project directories for which this instance is the default. The MCP server runs with your project directory as its working directory. If that directory is equal to or inside one of these paths, the instance becomes this session's default (the longest matching path wins). Paths must be absolute, and `~` expands to your home directory.
|
|
282
|
+
- `defaultInstance`: global fallback used when neither `JIRA_MCP_INSTANCE` nor a `paths` entry applies. The per-session choice never overwrites it.
|
|
283
|
+
|
|
284
|
+
The session default is used whenever a call has no issue or project key to route by, for example `jira_search`, `jira_search_users` without a key, or `jira_get_myself`. It is resolved at startup in this order:
|
|
285
|
+
|
|
286
|
+
1. `JIRA_MCP_INSTANCE=<name>` environment variable (ignored with a warning if no instance has that name).
|
|
287
|
+
2. The instance whose `paths` contain the working directory.
|
|
288
|
+
3. `defaultInstance` from the config (or the first instance if it's unset).
|
|
289
|
+
|
|
290
|
+
`jira_list_instances` shows the current session default and where it came from (env, path, or config). `jira_add_instance` with `setDefault: true` changes `defaultInstance` in the config, but the session keeps its env or path choice when one applies.
|
|
291
|
+
|
|
253
292
|
## Error Handling
|
|
254
293
|
|
|
255
294
|
The server provides clear error messages:
|
package/assets/icon.svg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#000000"><path d="M11.571 11.513H0a5.218 5.218 0 0 0 5.232 5.215h2.13v2.057A5.215 5.215 0 0 0 12.575 24V12.518a1.005 1.005 0 0 0-1.005-1.005zm5.723-5.756H5.736a5.215 5.215 0 0 0 5.215 5.214h2.129v2.058a5.218 5.218 0 0 0 5.215 5.214V6.758a1.001 1.001 0 0 0-1.001-1.001zM23.013 0H11.455a5.215 5.215 0 0 0 5.215 5.215h2.129v2.057A5.215 5.215 0 0 0 24 12.483V1.005A1.001 1.001 0 0 0 23.013 0Z"/></svg>
|
package/icons.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// serverInfo.icons for the MCP handshake: one single-colour silhouette sent
|
|
2
|
+
// twice, black for light backgrounds and white for dark ones.
|
|
3
|
+
// assets/icon.svg is the Jira logo from Simple Icons v13.21.0
|
|
4
|
+
// (https://simpleicons.org), CC0-1.0.
|
|
5
|
+
const fs = require("fs");
|
|
6
|
+
const path = require("path");
|
|
7
|
+
|
|
8
|
+
const SVG = fs.readFileSync(path.join(__dirname, "assets", "icon.svg"), "utf8").trim();
|
|
9
|
+
|
|
10
|
+
function icon(fill, theme) {
|
|
11
|
+
const svg = SVG.replace('fill="#000000"', `fill="${fill}"`);
|
|
12
|
+
return {
|
|
13
|
+
src: `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`,
|
|
14
|
+
mimeType: "image/svg+xml",
|
|
15
|
+
sizes: ["any"],
|
|
16
|
+
theme,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const ICONS = [icon("#000000", "light"), icon("#FFFFFF", "dark")];
|
|
21
|
+
|
|
22
|
+
module.exports = { ICONS };
|
package/index.js
CHANGED
|
@@ -19,6 +19,7 @@ const os = require("os");
|
|
|
19
19
|
const path = require("path");
|
|
20
20
|
const fetch = require("node-fetch");
|
|
21
21
|
const configStore = require("./config-store.js");
|
|
22
|
+
const { ICONS } = require("./icons.js");
|
|
22
23
|
const { spawn } = require("child_process");
|
|
23
24
|
|
|
24
25
|
const PKG_VERSION = require("./package.json").version;
|
|
@@ -106,10 +107,89 @@ if (Array.isArray(rawConfig.instances)) {
|
|
|
106
107
|
instances = [legacyInstance];
|
|
107
108
|
}
|
|
108
109
|
|
|
109
|
-
// Resolve default instance
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
110
|
+
// Resolve default instance. The config `defaultInstance` is the global
|
|
111
|
+
// fallback; this process's session default (used for routing fallbacks and
|
|
112
|
+
// fetch defaults) is picked by env JIRA_MCP_INSTANCE, then by the longest
|
|
113
|
+
// instance `paths` entry containing the working directory, then the config.
|
|
114
|
+
function realpathOrSelf(value) {
|
|
115
|
+
try {
|
|
116
|
+
return fs.realpathSync(value);
|
|
117
|
+
} catch {
|
|
118
|
+
return value;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function expandConfiguredPath(value) {
|
|
123
|
+
if (typeof value !== "string" || !value.trim()) return null;
|
|
124
|
+
let expanded = value.trim();
|
|
125
|
+
if (expanded === "~" || expanded.startsWith("~/")) {
|
|
126
|
+
expanded = path.join(process.env.HOME || "", expanded.slice(1));
|
|
127
|
+
}
|
|
128
|
+
if (!path.isAbsolute(expanded)) return null;
|
|
129
|
+
return realpathOrSelf(path.resolve(expanded));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function isSameOrInsidePath(dir, parent) {
|
|
133
|
+
const relative = path.relative(parent, dir);
|
|
134
|
+
return relative === "" ||
|
|
135
|
+
(relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function resolveSessionDefault(instanceList, configDefaultName, cwd, env) {
|
|
139
|
+
const configDefault =
|
|
140
|
+
(configDefaultName && instanceList.find((i) => i.name === configDefaultName)) ||
|
|
141
|
+
instanceList[0];
|
|
142
|
+
const envName = typeof env.JIRA_MCP_INSTANCE === "string" ? env.JIRA_MCP_INSTANCE.trim() : "";
|
|
143
|
+
if (envName) {
|
|
144
|
+
const envInstance = instanceList.find((i) => i.name === envName);
|
|
145
|
+
if (envInstance) return { instance: envInstance, source: "env" };
|
|
146
|
+
}
|
|
147
|
+
const ignoredEnv = envName || undefined;
|
|
148
|
+
|
|
149
|
+
let best = null;
|
|
150
|
+
if (cwd) {
|
|
151
|
+
const realCwd = realpathOrSelf(path.resolve(cwd));
|
|
152
|
+
for (const instance of instanceList) {
|
|
153
|
+
for (const configured of Array.isArray(instance.paths) ? instance.paths : []) {
|
|
154
|
+
const resolved = expandConfiguredPath(configured);
|
|
155
|
+
if (
|
|
156
|
+
resolved &&
|
|
157
|
+
isSameOrInsidePath(realCwd, resolved) &&
|
|
158
|
+
(!best || resolved.length > best.path.length)
|
|
159
|
+
) {
|
|
160
|
+
best = { instance, source: "path", path: resolved, ignoredEnv };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return best || { instance: configDefault, source: "config", ignoredEnv };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let sessionCwd = null;
|
|
169
|
+
try {
|
|
170
|
+
sessionCwd = process.cwd();
|
|
171
|
+
} catch {
|
|
172
|
+
// Working directory was removed; fall back to env/config only.
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let defaultInstance;
|
|
176
|
+
let sessionDefault;
|
|
177
|
+
function applySessionDefault() {
|
|
178
|
+
sessionDefault = resolveSessionDefault(instances, rawConfig.defaultInstance, sessionCwd, process.env);
|
|
179
|
+
defaultInstance = sessionDefault.instance;
|
|
180
|
+
}
|
|
181
|
+
applySessionDefault();
|
|
182
|
+
if (sessionDefault.ignoredEnv) {
|
|
183
|
+
console.error(`jira-mcp: JIRA_MCP_INSTANCE "${sessionDefault.ignoredEnv}" does not match a configured instance, ignoring it`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function describeSessionDefault() {
|
|
187
|
+
if (sessionDefault.source === "env") return "env JIRA_MCP_INSTANCE";
|
|
188
|
+
if (sessionDefault.source === "path") {
|
|
189
|
+
return `path ${sessionDefault.path} (working directory ${sessionCwd})`;
|
|
190
|
+
}
|
|
191
|
+
return "config defaultInstance";
|
|
192
|
+
}
|
|
113
193
|
|
|
114
194
|
// Instance resolution helpers
|
|
115
195
|
function getMappedInstancesForProject(projectPrefix) {
|
|
@@ -1117,7 +1197,35 @@ const ECHO_DIRECTIVE =
|
|
|
1117
1197
|
// Cache for user lookups to avoid repeated API calls
|
|
1118
1198
|
const userCache = new Map();
|
|
1119
1199
|
|
|
1120
|
-
|
|
1200
|
+
// Users assignable in any of the instance's configured projects. Visible even
|
|
1201
|
+
// when the account lacks the global "Browse users and groups" permission.
|
|
1202
|
+
// One request per project because /user/assignable/multiProjectSearch returns
|
|
1203
|
+
// the intersection (users assignable in ALL listed projects), not the union.
|
|
1204
|
+
// Results are merged in config order, deduped by accountId; a project that
|
|
1205
|
+
// errors (e.g. 403/404) is skipped.
|
|
1206
|
+
async function searchAssignableUsersInProjects(instance, query, maxResults) {
|
|
1207
|
+
const results = await Promise.all(
|
|
1208
|
+
(instance.projects || []).map((project) =>
|
|
1209
|
+
fetchJira(
|
|
1210
|
+
`/user/assignable/search?project=${encodeURIComponent(project)}&query=${encodeURIComponent(query)}&maxResults=${maxResults}`,
|
|
1211
|
+
{},
|
|
1212
|
+
instance,
|
|
1213
|
+
).catch(() => []),
|
|
1214
|
+
),
|
|
1215
|
+
);
|
|
1216
|
+
const users = [];
|
|
1217
|
+
const seen = new Set();
|
|
1218
|
+
for (const projectUsers of results) {
|
|
1219
|
+
for (const user of Array.isArray(projectUsers) ? projectUsers : []) {
|
|
1220
|
+
if (seen.has(user.accountId)) continue;
|
|
1221
|
+
seen.add(user.accountId);
|
|
1222
|
+
users.push(user);
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
return users.slice(0, maxResults);
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
async function searchUser(query, instance = defaultInstance, issueKey = null) {
|
|
1121
1229
|
// Check cache first (instance-aware)
|
|
1122
1230
|
const cacheKey = `${instance.name}:${query.toLowerCase()}`;
|
|
1123
1231
|
if (userCache.has(cacheKey)) {
|
|
@@ -1126,11 +1234,22 @@ async function searchUser(query, instance = defaultInstance) {
|
|
|
1126
1234
|
|
|
1127
1235
|
try {
|
|
1128
1236
|
// Search for users by display name
|
|
1129
|
-
|
|
1237
|
+
let users = await fetchJira(
|
|
1130
1238
|
`/user/search?query=${encodeURIComponent(query)}&maxResults=5`,
|
|
1131
1239
|
{},
|
|
1132
1240
|
instance,
|
|
1133
|
-
);
|
|
1241
|
+
).catch(() => []);
|
|
1242
|
+
if (!Array.isArray(users) || users.length === 0) {
|
|
1243
|
+
// Global search is empty without "Browse users and groups" — fall back
|
|
1244
|
+
// to users assignable to the issue, else to the instance's projects.
|
|
1245
|
+
users = issueKey
|
|
1246
|
+
? await fetchJira(
|
|
1247
|
+
`/user/assignable/search?issueKey=${encodeURIComponent(issueKey)}&query=${encodeURIComponent(query)}&maxResults=5`,
|
|
1248
|
+
{},
|
|
1249
|
+
instance,
|
|
1250
|
+
)
|
|
1251
|
+
: await searchAssignableUsersInProjects(instance, query, 5);
|
|
1252
|
+
}
|
|
1134
1253
|
if (users && users.length > 0) {
|
|
1135
1254
|
// Find best match - prefer exact match, then starts with, then contains
|
|
1136
1255
|
const exactMatch = users.find(
|
|
@@ -1156,7 +1275,7 @@ async function searchUser(query, instance = defaultInstance) {
|
|
|
1156
1275
|
|
|
1157
1276
|
// Parse text with @mentions and build ADF content
|
|
1158
1277
|
// Parse inline formatting: **bold**, *italic*, `code`, [links](url), @mentions
|
|
1159
|
-
async function parseInlineFormatting(text, instance = defaultInstance) {
|
|
1278
|
+
async function parseInlineFormatting(text, instance = defaultInstance, issueKey = null) {
|
|
1160
1279
|
const nodes = [];
|
|
1161
1280
|
// Links, bold, italic, inline code, mentions — links must come first to avoid ** inside link text being parsed as bold
|
|
1162
1281
|
const regex = /(\[([^\]]+)\]\(([^)]+)\)|`(.+?)`|\*\*(.+?)\*\*|\*(.+?)\*|~~(.+?)~~|@([A-Z][a-zA-Zà-ÿ]*(?:\s[A-Z][a-zA-Zà-ÿ]*)*))/g;
|
|
@@ -1192,7 +1311,7 @@ async function parseInlineFormatting(text, instance = defaultInstance) {
|
|
|
1192
1311
|
// @Mention — regex may greedily capture extra capitalized words beyond the actual name.
|
|
1193
1312
|
// After resolving, only consume the portion matching the display name.
|
|
1194
1313
|
const captured = match[8].trim();
|
|
1195
|
-
const user = await searchUser(captured, instance);
|
|
1314
|
+
const user = await searchUser(captured, instance, issueKey);
|
|
1196
1315
|
if (user) {
|
|
1197
1316
|
nodes.push({
|
|
1198
1317
|
type: "mention",
|
|
@@ -1302,7 +1421,7 @@ function listItemMatch(line) {
|
|
|
1302
1421
|
// Recursively parse a (possibly nested) markdown list starting at lines[startIdx].
|
|
1303
1422
|
// baseIndent is the indentation of items at this level; more-indented items are
|
|
1304
1423
|
// folded into the previous item as a nested list. Returns {node, nextIndex}.
|
|
1305
|
-
async function parseListBlock(lines, startIdx, baseIndent, instance) {
|
|
1424
|
+
async function parseListBlock(lines, startIdx, baseIndent, instance, issueKey = null) {
|
|
1306
1425
|
const items = [];
|
|
1307
1426
|
let i = startIdx;
|
|
1308
1427
|
let ordered = null;
|
|
@@ -1313,7 +1432,7 @@ async function parseListBlock(lines, startIdx, baseIndent, instance) {
|
|
|
1313
1432
|
if (m.indent < baseIndent) break; // dedent: belongs to a parent list
|
|
1314
1433
|
if (m.indent > baseIndent) {
|
|
1315
1434
|
// Deeper indent: nest under the previous item at this level.
|
|
1316
|
-
const sub = await parseListBlock(lines, i, m.indent, instance);
|
|
1435
|
+
const sub = await parseListBlock(lines, i, m.indent, instance, issueKey);
|
|
1317
1436
|
if (items.length > 0) {
|
|
1318
1437
|
items[items.length - 1].content.push(sub.node);
|
|
1319
1438
|
} else {
|
|
@@ -1324,7 +1443,7 @@ async function parseListBlock(lines, startIdx, baseIndent, instance) {
|
|
|
1324
1443
|
}
|
|
1325
1444
|
if (ordered === null) ordered = m.ordered; // list type set by first item
|
|
1326
1445
|
else if (m.ordered !== ordered) break; // marker switched: caller starts a new list
|
|
1327
|
-
const inlineContent = await parseInlineFormatting(m.text, instance);
|
|
1446
|
+
const inlineContent = await parseInlineFormatting(m.text, instance, issueKey);
|
|
1328
1447
|
items.push({
|
|
1329
1448
|
type: "listItem",
|
|
1330
1449
|
content: [{ type: "paragraph", content: inlineContent }],
|
|
@@ -1338,7 +1457,7 @@ async function parseListBlock(lines, startIdx, baseIndent, instance) {
|
|
|
1338
1457
|
}
|
|
1339
1458
|
|
|
1340
1459
|
// Parse text with markdown formatting and @mentions, build ADF content
|
|
1341
|
-
async function buildCommentADF(text, instance = defaultInstance) {
|
|
1460
|
+
async function buildCommentADF(text, instance = defaultInstance, issueKey = null) {
|
|
1342
1461
|
// Sanitize: replace em dashes and en dashes with hyphen
|
|
1343
1462
|
text = text.replace(/[—–]/g, "-");
|
|
1344
1463
|
|
|
@@ -1387,7 +1506,7 @@ async function buildCommentADF(text, instance = defaultInstance) {
|
|
|
1387
1506
|
if (headingMatch) {
|
|
1388
1507
|
const level = headingMatch[1].length;
|
|
1389
1508
|
const headingText = headingMatch[2];
|
|
1390
|
-
const inlineContent = await parseInlineFormatting(headingText, instance);
|
|
1509
|
+
const inlineContent = await parseInlineFormatting(headingText, instance, issueKey);
|
|
1391
1510
|
content.push({
|
|
1392
1511
|
type: "heading",
|
|
1393
1512
|
attrs: { level },
|
|
@@ -1404,7 +1523,7 @@ async function buildCommentADF(text, instance = defaultInstance) {
|
|
|
1404
1523
|
quoteLines.push(lines[i].trim().substring(2));
|
|
1405
1524
|
i++;
|
|
1406
1525
|
}
|
|
1407
|
-
const quoteContent = await parseInlineFormatting(quoteLines.join("\n"), instance);
|
|
1526
|
+
const quoteContent = await parseInlineFormatting(quoteLines.join("\n"), instance, issueKey);
|
|
1408
1527
|
content.push({
|
|
1409
1528
|
type: "blockquote",
|
|
1410
1529
|
content: [{ type: "paragraph", content: quoteContent }],
|
|
@@ -1436,7 +1555,7 @@ async function buildCommentADF(text, instance = defaultInstance) {
|
|
|
1436
1555
|
const cellType = r === 0 && isHeader ? "tableHeader" : "tableCell";
|
|
1437
1556
|
const adfCells = [];
|
|
1438
1557
|
for (const cellText of tableRows[r]) {
|
|
1439
|
-
const inlineContent = await parseInlineFormatting(cellText, instance);
|
|
1558
|
+
const inlineContent = await parseInlineFormatting(cellText, instance, issueKey);
|
|
1440
1559
|
adfCells.push({
|
|
1441
1560
|
type: cellType,
|
|
1442
1561
|
content: [{ type: "paragraph", content: inlineContent }],
|
|
@@ -1457,6 +1576,7 @@ async function buildCommentADF(text, instance = defaultInstance) {
|
|
|
1457
1576
|
i,
|
|
1458
1577
|
listStart.indent,
|
|
1459
1578
|
instance,
|
|
1579
|
+
issueKey,
|
|
1460
1580
|
);
|
|
1461
1581
|
content.push(node);
|
|
1462
1582
|
i = nextIndex;
|
|
@@ -1478,7 +1598,7 @@ async function buildCommentADF(text, instance = defaultInstance) {
|
|
|
1478
1598
|
!(lines[i].trim().startsWith("|") && lines[i].trim().endsWith("|"))
|
|
1479
1599
|
) {
|
|
1480
1600
|
if (paragraphContent.length > 0) paragraphContent.push({ type: "hardBreak" });
|
|
1481
|
-
const inlineNodes = await parseInlineFormatting(lines[i].trim(), instance);
|
|
1601
|
+
const inlineNodes = await parseInlineFormatting(lines[i].trim(), instance, issueKey);
|
|
1482
1602
|
paragraphContent.push(...inlineNodes);
|
|
1483
1603
|
i++;
|
|
1484
1604
|
}
|
|
@@ -2340,7 +2460,7 @@ async function getChangelogsBulk(jql, maxResults = 50, instance = defaultInstanc
|
|
|
2340
2460
|
|
|
2341
2461
|
const SERVER_INSTRUCTIONS = `# jira-mcp usage rules
|
|
2342
2462
|
|
|
2343
|
-
- \`instance\` (optional, on most tools): multi-instance setups only. Omit it — it auto-detects from the issue key prefix, else uses
|
|
2463
|
+
- \`instance\` (optional, on most tools): multi-instance setups only. Omit it — it auto-detects from the issue key prefix, else uses this session's default instance (see \`jira_list_instances\`).
|
|
2344
2464
|
- \`dryRun\` (optional, on mutating tools): if true, returns the HTTP request that would be sent (method, endpoint, body) without calling Atlassian. Multi-step tools only show the first write.
|
|
2345
2465
|
${confluenceMode !== "always" ? "- Confluence tools are hidden until activated: call `confluence_enable` first when a task needs Confluence (pages, spaces, page comments, labels, attachments).\n" : ""}
|
|
2346
2466
|
## Writing Jira comments (jira_add_comment / jira_reply_comment / jira_edit_comment)
|
|
@@ -2352,7 +2472,7 @@ After the tool succeeds, its response echoes the posted comment as a markdown bl
|
|
|
2352
2472
|
Structure with Markdown: a one/two-sentence overview, then ## / ### headings (Overview, Details, Acceptance Criteria), '-' bullet lists, numbered steps, tables for structured data, **bold** key terms, fenced \`\`\` blocks for code/payloads. Blank line between blocks. Concise and scannable. Never use em/en dashes.`;
|
|
2353
2473
|
|
|
2354
2474
|
const server = new Server(
|
|
2355
|
-
{ name: "jira-mcp", version:
|
|
2475
|
+
{ name: "jira-mcp", version: PKG_VERSION, icons: ICONS },
|
|
2356
2476
|
{ capabilities: { tools: { listChanged: true } }, instructions: SERVER_INSTRUCTIONS },
|
|
2357
2477
|
);
|
|
2358
2478
|
|
|
@@ -2740,7 +2860,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
2740
2860
|
},
|
|
2741
2861
|
setDefault: {
|
|
2742
2862
|
type: "boolean",
|
|
2743
|
-
description: "Set this instance as the default (default: false)",
|
|
2863
|
+
description: "Set this instance as the config default (default: false). JIRA_MCP_INSTANCE or an instance whose paths contain the working directory still takes precedence for the current session.",
|
|
2744
2864
|
},
|
|
2745
2865
|
defaultTeam: {
|
|
2746
2866
|
type: "string",
|
|
@@ -2776,7 +2896,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
2776
2896
|
{
|
|
2777
2897
|
name: "jira_list_instances",
|
|
2778
2898
|
description:
|
|
2779
|
-
"List all configured Jira instances with their names, URLs, project prefixes, and which is the default.",
|
|
2899
|
+
"List all configured Jira instances with their names, URLs, project prefixes, paths, and which is the session default and why (env, path, or config).",
|
|
2780
2900
|
inputSchema: {
|
|
2781
2901
|
type: "object",
|
|
2782
2902
|
properties: {},
|
|
@@ -3858,6 +3978,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3858
3978
|
{},
|
|
3859
3979
|
inst,
|
|
3860
3980
|
);
|
|
3981
|
+
} else if (!hasAssignmentContext && (!users || users.length === 0)) {
|
|
3982
|
+
users = await searchAssignableUsersInProjects(inst, args.query, maxResults);
|
|
3861
3983
|
}
|
|
3862
3984
|
|
|
3863
3985
|
if (!users || users.length === 0) {
|
|
@@ -3947,7 +4069,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3947
4069
|
} else if (name === "jira_add_comment") {
|
|
3948
4070
|
const inst = args.instance ? getInstanceByName(args.instance) : getInstanceForKey(args.issueKey);
|
|
3949
4071
|
// Build ADF content with mention support
|
|
3950
|
-
const adfContent = await buildCommentADF(args.comment, inst);
|
|
4072
|
+
const adfContent = await buildCommentADF(args.comment, inst, args.issueKey);
|
|
3951
4073
|
const body = {
|
|
3952
4074
|
body: {
|
|
3953
4075
|
version: 1,
|
|
@@ -4042,7 +4164,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4042
4164
|
} else if (name === "jira_edit_comment") {
|
|
4043
4165
|
const inst = args.instance ? getInstanceByName(args.instance) : getInstanceForKey(args.issueKey);
|
|
4044
4166
|
// Build ADF content with mention support
|
|
4045
|
-
const adfContent = await buildCommentADF(args.comment, inst);
|
|
4167
|
+
const adfContent = await buildCommentADF(args.comment, inst, args.issueKey);
|
|
4046
4168
|
const body = {
|
|
4047
4169
|
body: {
|
|
4048
4170
|
version: 1,
|
|
@@ -4455,10 +4577,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4455
4577
|
instances.push(savedInstance);
|
|
4456
4578
|
activeInstance = savedInstance;
|
|
4457
4579
|
}
|
|
4458
|
-
defaultInstance =
|
|
4459
|
-
instances.find((instance) => instance.name === persisted.defaultInstance) ||
|
|
4460
|
-
instances[0];
|
|
4461
4580
|
rawConfig.defaultInstance = persisted.defaultInstance;
|
|
4581
|
+
applySessionDefault();
|
|
4462
4582
|
|
|
4463
4583
|
const action = persisted.isUpdate ? "Updated" : "Added";
|
|
4464
4584
|
const savedBaseUrl = activeInstance.baseUrl;
|
|
@@ -4466,7 +4586,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4466
4586
|
const projectTeams = activeInstance.projectTeams || {};
|
|
4467
4587
|
let text = `${action} instance "${instName}" (${savedBaseUrl}).`;
|
|
4468
4588
|
if (savedProjects.length > 0) text += ` Projects: ${savedProjects.join(", ")}.`;
|
|
4469
|
-
if (args.setDefault)
|
|
4589
|
+
if (args.setDefault) {
|
|
4590
|
+
text += " Set as default.";
|
|
4591
|
+
if (defaultInstance.name !== instName) {
|
|
4592
|
+
text += ` This session keeps using "${defaultInstance.name}" (from ${describeSessionDefault()}).`;
|
|
4593
|
+
}
|
|
4594
|
+
}
|
|
4470
4595
|
if (projectTeamChange) {
|
|
4471
4596
|
const pt = projectTeams[projectTeamChange.key];
|
|
4472
4597
|
if (pt === "none") {
|
|
@@ -4528,10 +4653,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4528
4653
|
// Update in-memory state only after the atomic config replacement succeeds.
|
|
4529
4654
|
const idx = instances.findIndex((i) => i.name === instName);
|
|
4530
4655
|
if (idx >= 0) instances.splice(idx, 1);
|
|
4531
|
-
defaultInstance =
|
|
4532
|
-
instances.find((instance) => instance.name === removal.defaultInstance) ||
|
|
4533
|
-
instances[0];
|
|
4534
4656
|
rawConfig.defaultInstance = removal.defaultInstance;
|
|
4657
|
+
applySessionDefault();
|
|
4535
4658
|
|
|
4536
4659
|
return { content: [{ type: "text", text: `Removed instance "${instName}".` }] };
|
|
4537
4660
|
|
|
@@ -4539,12 +4662,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4539
4662
|
if (instances.length === 0) {
|
|
4540
4663
|
return { content: [{ type: "text", text: "No instances configured." }] };
|
|
4541
4664
|
}
|
|
4542
|
-
const
|
|
4665
|
+
const configDefault =
|
|
4666
|
+
instances.find((inst) => inst.name === rawConfig.defaultInstance) || instances[0];
|
|
4543
4667
|
let text = `# Configured Jira Instances (${instances.length})\n\n`;
|
|
4668
|
+
text += `Session default: **${defaultInstance.name}** (from ${describeSessionDefault()}). Config default: ${configDefault.name}.\n`;
|
|
4669
|
+
if (sessionDefault.ignoredEnv) {
|
|
4670
|
+
text += `JIRA_MCP_INSTANCE "${sessionDefault.ignoredEnv}" does not match a configured instance and is ignored.\n`;
|
|
4671
|
+
}
|
|
4672
|
+
text += "\n";
|
|
4544
4673
|
const missingTeam = [];
|
|
4545
4674
|
for (const inst of instances) {
|
|
4546
|
-
|
|
4675
|
+
let isDefault = inst.name === defaultInstance.name ? " **(default)**" : "";
|
|
4676
|
+
if (inst.name === configDefault.name && inst.name !== defaultInstance.name) {
|
|
4677
|
+
isDefault = " (config default)";
|
|
4678
|
+
}
|
|
4547
4679
|
const projs = inst.projects?.length > 0 ? `\n Projects: ${inst.projects.join(", ")}` : "";
|
|
4680
|
+
const paths = Array.isArray(inst.paths) && inst.paths.length > 0 ? `\n Paths: ${inst.paths.join(", ")}` : "";
|
|
4548
4681
|
const team = inst.defaultTeam === "none" ? "\n Default team: None (disabled)" : inst.defaultTeam ? `\n Default team: ${inst.defaultTeam.name}` : "";
|
|
4549
4682
|
let projTeams = "";
|
|
4550
4683
|
if (inst.projectTeams && Object.keys(inst.projectTeams).length > 0) {
|
|
@@ -4553,7 +4686,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4553
4686
|
projTeams += `\n ${pk} team: ${ptName}`;
|
|
4554
4687
|
}
|
|
4555
4688
|
}
|
|
4556
|
-
text += `- **${inst.name}**${isDefault}: ${inst.baseUrl} (${inst.email})${projs}${team}${projTeams}\n`;
|
|
4689
|
+
text += `- **${inst.name}**${isDefault}: ${inst.baseUrl} (${inst.email})${projs}${paths}${team}${projTeams}\n`;
|
|
4557
4690
|
if (!inst.defaultTeam) missingTeam.push(inst);
|
|
4558
4691
|
}
|
|
4559
4692
|
if (missingTeam.length > 0) {
|
|
@@ -5768,6 +5901,7 @@ if (typeof module !== "undefined") {
|
|
|
5768
5901
|
checkRateLimit, _setClockForTests, _resetBucketsForTests,
|
|
5769
5902
|
extractRoutingPrefixes, probeJiraProject, discoverProjectInstance,
|
|
5770
5903
|
persistDiscoveredProjects, resolveInstanceForTool, _injectDryRunSchema,
|
|
5904
|
+
resolveSessionDefault,
|
|
5771
5905
|
AUDIT_LOG_PATH,
|
|
5772
5906
|
};
|
|
5773
5907
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rui.branco/jira-mcp",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.14",
|
|
4
4
|
"description": "Jira & Confluence MCP server for any MCP-compatible AI client (Claude Code, Codex, Antigravity, Cursor, etc.) - fetch tickets, search with JQL, update tickets, manage comments, change status, get Figma designs. Optional per-tool scopes, dry-run, audit log, and rate limiting.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"index.js",
|
|
11
11
|
"setup.js",
|
|
12
|
-
"config-store.js"
|
|
12
|
+
"config-store.js",
|
|
13
|
+
"icons.js",
|
|
14
|
+
"assets/icon.svg"
|
|
13
15
|
],
|
|
14
16
|
"scripts": {
|
|
15
17
|
"setup": "node setup.js",
|