@rui.branco/jira-mcp 1.7.11 → 1.7.13

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
@@ -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/config-store.js CHANGED
@@ -5,7 +5,7 @@ const os = require("os");
5
5
 
6
6
  const configPath = path.resolve(
7
7
  process.env.JIRA_MCP_CONFIG_PATH ||
8
- path.join(process.env.HOME, ".config/jira-mcp/config.json"),
8
+ path.join(os.homedir(), ".config/jira-mcp/config.json"),
9
9
  );
10
10
  const configuredLockWaitMs = Number(process.env.JIRA_MCP_LOCK_WAIT_MS);
11
11
  const LOCK_WAIT_MS =
package/index.js CHANGED
@@ -15,37 +15,47 @@ const {
15
15
  ListToolsRequestSchema,
16
16
  } = require("@modelcontextprotocol/sdk/types.js");
17
17
  const fs = require("fs");
18
+ const os = require("os");
18
19
  const path = require("path");
19
20
  const fetch = require("node-fetch");
20
21
  const configStore = require("./config-store.js");
21
- const { spawn, execSync } = require("child_process");
22
+ const { spawn } = require("child_process");
22
23
 
23
- // Auto-update: check GitHub for new commits, install in background
24
- const GITHUB_REPO = "rui-branco/jira-mcp";
25
- const INSTALLED_SHA_FILE = path.join(__dirname, ".installed-sha");
26
- try {
27
- const localSha = fs.existsSync(INSTALLED_SHA_FILE)
28
- ? fs.readFileSync(INSTALLED_SHA_FILE, "utf-8").trim()
29
- : "";
30
- const remoteSha = execSync(
31
- `git ls-remote https://github.com/${GITHUB_REPO}.git HEAD`,
32
- { stdio: "pipe", timeout: 5000 },
33
- )
34
- .toString()
35
- .split("\t")[0]
36
- .trim();
37
- if (remoteSha && remoteSha !== localSha) {
38
- const child = spawn(
39
- "sh",
40
- [
41
- "-c",
42
- `npm install -g git+ssh://git@github.com/${GITHUB_REPO}.git && echo "${remoteSha}" > "${INSTALLED_SHA_FILE}"`,
43
- ],
44
- { stdio: "ignore", detached: true },
24
+ const PKG_VERSION = require("./package.json").version;
25
+
26
+ // Auto-update: ask the npm registry on every start what the latest published
27
+ // version is, and install it in the background when it isn't the one we're
28
+ // running. The package ships through npm, so there's no git involved: a plain
29
+ // fetch plus an npm install. Runs detached from startup so the server begins
30
+ // serving stdio immediately.
31
+ const NPM_PACKAGE = "@rui.branco/jira-mcp";
32
+ (async () => {
33
+ try {
34
+ const res = await fetch(
35
+ `https://registry.npmjs.org/${encodeURIComponent(NPM_PACKAGE)}/latest`,
36
+ { signal: AbortSignal.timeout(5000) },
45
37
  );
46
- child.unref();
38
+ const latest = (await res.json()).version;
39
+ if (latest && latest !== PKG_VERSION) {
40
+ // Passed as one shell string rather than an argv array: npm is npm.cmd on
41
+ // Windows, and since Node 18.20.2 spawning a .cmd without a shell throws
42
+ // EINVAL outright (CVE-2024-27980 hardening). shell:true picks cmd.exe
43
+ // there and /bin/sh elsewhere, so this stays portable — unlike the old
44
+ // hardcoded `sh`, which simply doesn't exist on Windows. The command is a
45
+ // fixed literal with no interpolation, so there's nothing to inject.
46
+ const child = spawn(`npm install -g ${NPM_PACKAGE}`, {
47
+ shell: true,
48
+ stdio: "ignore",
49
+ });
50
+ // A spawn failure (npm missing from PATH) arrives as an async 'error'
51
+ // event, which no try/catch around the spawn can see — left unhandled it
52
+ // would take the whole server down at startup. Swallow it here.
53
+ child.on("error", () => {});
54
+ }
55
+ } catch {
56
+ /* offline, slow registry, or bad response — no-op */
47
57
  }
48
- } catch {}
58
+ })();
49
59
 
50
60
  // Load Jira config (supports single-instance and multi-instance formats)
51
61
  const rawConfig = configStore.loadConfigStrict();
@@ -96,10 +106,89 @@ if (Array.isArray(rawConfig.instances)) {
96
106
  instances = [legacyInstance];
97
107
  }
98
108
 
99
- // Resolve default instance
100
- let defaultInstance =
101
- (rawConfig.defaultInstance && instances.find((i) => i.name === rawConfig.defaultInstance)) ||
102
- instances[0];
109
+ // Resolve default instance. The config `defaultInstance` is the global
110
+ // fallback; this process's session default (used for routing fallbacks and
111
+ // fetch defaults) is picked by env JIRA_MCP_INSTANCE, then by the longest
112
+ // instance `paths` entry containing the working directory, then the config.
113
+ function realpathOrSelf(value) {
114
+ try {
115
+ return fs.realpathSync(value);
116
+ } catch {
117
+ return value;
118
+ }
119
+ }
120
+
121
+ function expandConfiguredPath(value) {
122
+ if (typeof value !== "string" || !value.trim()) return null;
123
+ let expanded = value.trim();
124
+ if (expanded === "~" || expanded.startsWith("~/")) {
125
+ expanded = path.join(process.env.HOME || "", expanded.slice(1));
126
+ }
127
+ if (!path.isAbsolute(expanded)) return null;
128
+ return realpathOrSelf(path.resolve(expanded));
129
+ }
130
+
131
+ function isSameOrInsidePath(dir, parent) {
132
+ const relative = path.relative(parent, dir);
133
+ return relative === "" ||
134
+ (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
135
+ }
136
+
137
+ function resolveSessionDefault(instanceList, configDefaultName, cwd, env) {
138
+ const configDefault =
139
+ (configDefaultName && instanceList.find((i) => i.name === configDefaultName)) ||
140
+ instanceList[0];
141
+ const envName = typeof env.JIRA_MCP_INSTANCE === "string" ? env.JIRA_MCP_INSTANCE.trim() : "";
142
+ if (envName) {
143
+ const envInstance = instanceList.find((i) => i.name === envName);
144
+ if (envInstance) return { instance: envInstance, source: "env" };
145
+ }
146
+ const ignoredEnv = envName || undefined;
147
+
148
+ let best = null;
149
+ if (cwd) {
150
+ const realCwd = realpathOrSelf(path.resolve(cwd));
151
+ for (const instance of instanceList) {
152
+ for (const configured of Array.isArray(instance.paths) ? instance.paths : []) {
153
+ const resolved = expandConfiguredPath(configured);
154
+ if (
155
+ resolved &&
156
+ isSameOrInsidePath(realCwd, resolved) &&
157
+ (!best || resolved.length > best.path.length)
158
+ ) {
159
+ best = { instance, source: "path", path: resolved, ignoredEnv };
160
+ }
161
+ }
162
+ }
163
+ }
164
+ return best || { instance: configDefault, source: "config", ignoredEnv };
165
+ }
166
+
167
+ let sessionCwd = null;
168
+ try {
169
+ sessionCwd = process.cwd();
170
+ } catch {
171
+ // Working directory was removed; fall back to env/config only.
172
+ }
173
+
174
+ let defaultInstance;
175
+ let sessionDefault;
176
+ function applySessionDefault() {
177
+ sessionDefault = resolveSessionDefault(instances, rawConfig.defaultInstance, sessionCwd, process.env);
178
+ defaultInstance = sessionDefault.instance;
179
+ }
180
+ applySessionDefault();
181
+ if (sessionDefault.ignoredEnv) {
182
+ console.error(`jira-mcp: JIRA_MCP_INSTANCE "${sessionDefault.ignoredEnv}" does not match a configured instance, ignoring it`);
183
+ }
184
+
185
+ function describeSessionDefault() {
186
+ if (sessionDefault.source === "env") return "env JIRA_MCP_INSTANCE";
187
+ if (sessionDefault.source === "path") {
188
+ return `path ${sessionDefault.path} (working directory ${sessionCwd})`;
189
+ }
190
+ return "config defaultInstance";
191
+ }
103
192
 
104
193
  // Instance resolution helpers
105
194
  function getMappedInstancesForProject(projectPrefix) {
@@ -142,7 +231,7 @@ function getInstanceByName(name) {
142
231
  // Load Figma config (optional)
143
232
  let figmaConfig = null;
144
233
  const figmaConfigPath = path.join(
145
- process.env.HOME,
234
+ os.homedir(),
146
235
  ".config/figma-mcp/config.json",
147
236
  );
148
237
  try {
@@ -155,11 +244,11 @@ try {
155
244
 
156
245
  // Directories
157
246
  const attachmentDir = path.join(
158
- process.env.HOME,
247
+ os.homedir(),
159
248
  ".config/jira-mcp/attachments",
160
249
  );
161
250
  const figmaExportsDir = path.join(
162
- process.env.HOME,
251
+ os.homedir(),
163
252
  ".config/figma-mcp/exports",
164
253
  );
165
254
 
@@ -315,7 +404,7 @@ function recordDryRunCall(call) {
315
404
 
316
405
  // ---- Audit log ----
317
406
  const AUDIT_LOG_PATH = path.join(
318
- process.env.HOME || "/tmp",
407
+ os.homedir(),
319
408
  ".config/jira-mcp/audit.log",
320
409
  );
321
410
  const AUDIT_LOG_MAX_BYTES = 10 * 1024 * 1024;
@@ -905,7 +994,7 @@ async function downloadAttachment(url, filename, issueKey, instance) {
905
994
  // ============ CONFLUENCE FUNCTIONS ============
906
995
 
907
996
  const confluenceAttachmentDir = path.join(
908
- process.env.HOME,
997
+ os.homedir(),
909
998
  ".config/jira-mcp/confluence-attachments",
910
999
  );
911
1000
  if (!fs.existsSync(confluenceAttachmentDir)) {
@@ -1107,7 +1196,35 @@ const ECHO_DIRECTIVE =
1107
1196
  // Cache for user lookups to avoid repeated API calls
1108
1197
  const userCache = new Map();
1109
1198
 
1110
- async function searchUser(query, instance = defaultInstance) {
1199
+ // Users assignable in any of the instance's configured projects. Visible even
1200
+ // when the account lacks the global "Browse users and groups" permission.
1201
+ // One request per project because /user/assignable/multiProjectSearch returns
1202
+ // the intersection (users assignable in ALL listed projects), not the union.
1203
+ // Results are merged in config order, deduped by accountId; a project that
1204
+ // errors (e.g. 403/404) is skipped.
1205
+ async function searchAssignableUsersInProjects(instance, query, maxResults) {
1206
+ const results = await Promise.all(
1207
+ (instance.projects || []).map((project) =>
1208
+ fetchJira(
1209
+ `/user/assignable/search?project=${encodeURIComponent(project)}&query=${encodeURIComponent(query)}&maxResults=${maxResults}`,
1210
+ {},
1211
+ instance,
1212
+ ).catch(() => []),
1213
+ ),
1214
+ );
1215
+ const users = [];
1216
+ const seen = new Set();
1217
+ for (const projectUsers of results) {
1218
+ for (const user of Array.isArray(projectUsers) ? projectUsers : []) {
1219
+ if (seen.has(user.accountId)) continue;
1220
+ seen.add(user.accountId);
1221
+ users.push(user);
1222
+ }
1223
+ }
1224
+ return users.slice(0, maxResults);
1225
+ }
1226
+
1227
+ async function searchUser(query, instance = defaultInstance, issueKey = null) {
1111
1228
  // Check cache first (instance-aware)
1112
1229
  const cacheKey = `${instance.name}:${query.toLowerCase()}`;
1113
1230
  if (userCache.has(cacheKey)) {
@@ -1116,11 +1233,22 @@ async function searchUser(query, instance = defaultInstance) {
1116
1233
 
1117
1234
  try {
1118
1235
  // Search for users by display name
1119
- const users = await fetchJira(
1236
+ let users = await fetchJira(
1120
1237
  `/user/search?query=${encodeURIComponent(query)}&maxResults=5`,
1121
1238
  {},
1122
1239
  instance,
1123
- );
1240
+ ).catch(() => []);
1241
+ if (!Array.isArray(users) || users.length === 0) {
1242
+ // Global search is empty without "Browse users and groups" — fall back
1243
+ // to users assignable to the issue, else to the instance's projects.
1244
+ users = issueKey
1245
+ ? await fetchJira(
1246
+ `/user/assignable/search?issueKey=${encodeURIComponent(issueKey)}&query=${encodeURIComponent(query)}&maxResults=5`,
1247
+ {},
1248
+ instance,
1249
+ )
1250
+ : await searchAssignableUsersInProjects(instance, query, 5);
1251
+ }
1124
1252
  if (users && users.length > 0) {
1125
1253
  // Find best match - prefer exact match, then starts with, then contains
1126
1254
  const exactMatch = users.find(
@@ -1146,7 +1274,7 @@ async function searchUser(query, instance = defaultInstance) {
1146
1274
 
1147
1275
  // Parse text with @mentions and build ADF content
1148
1276
  // Parse inline formatting: **bold**, *italic*, `code`, [links](url), @mentions
1149
- async function parseInlineFormatting(text, instance = defaultInstance) {
1277
+ async function parseInlineFormatting(text, instance = defaultInstance, issueKey = null) {
1150
1278
  const nodes = [];
1151
1279
  // Links, bold, italic, inline code, mentions — links must come first to avoid ** inside link text being parsed as bold
1152
1280
  const regex = /(\[([^\]]+)\]\(([^)]+)\)|`(.+?)`|\*\*(.+?)\*\*|\*(.+?)\*|~~(.+?)~~|@([A-Z][a-zA-Zà-ÿ]*(?:\s[A-Z][a-zA-Zà-ÿ]*)*))/g;
@@ -1182,7 +1310,7 @@ async function parseInlineFormatting(text, instance = defaultInstance) {
1182
1310
  // @Mention — regex may greedily capture extra capitalized words beyond the actual name.
1183
1311
  // After resolving, only consume the portion matching the display name.
1184
1312
  const captured = match[8].trim();
1185
- const user = await searchUser(captured, instance);
1313
+ const user = await searchUser(captured, instance, issueKey);
1186
1314
  if (user) {
1187
1315
  nodes.push({
1188
1316
  type: "mention",
@@ -1292,7 +1420,7 @@ function listItemMatch(line) {
1292
1420
  // Recursively parse a (possibly nested) markdown list starting at lines[startIdx].
1293
1421
  // baseIndent is the indentation of items at this level; more-indented items are
1294
1422
  // folded into the previous item as a nested list. Returns {node, nextIndex}.
1295
- async function parseListBlock(lines, startIdx, baseIndent, instance) {
1423
+ async function parseListBlock(lines, startIdx, baseIndent, instance, issueKey = null) {
1296
1424
  const items = [];
1297
1425
  let i = startIdx;
1298
1426
  let ordered = null;
@@ -1303,7 +1431,7 @@ async function parseListBlock(lines, startIdx, baseIndent, instance) {
1303
1431
  if (m.indent < baseIndent) break; // dedent: belongs to a parent list
1304
1432
  if (m.indent > baseIndent) {
1305
1433
  // Deeper indent: nest under the previous item at this level.
1306
- const sub = await parseListBlock(lines, i, m.indent, instance);
1434
+ const sub = await parseListBlock(lines, i, m.indent, instance, issueKey);
1307
1435
  if (items.length > 0) {
1308
1436
  items[items.length - 1].content.push(sub.node);
1309
1437
  } else {
@@ -1314,7 +1442,7 @@ async function parseListBlock(lines, startIdx, baseIndent, instance) {
1314
1442
  }
1315
1443
  if (ordered === null) ordered = m.ordered; // list type set by first item
1316
1444
  else if (m.ordered !== ordered) break; // marker switched: caller starts a new list
1317
- const inlineContent = await parseInlineFormatting(m.text, instance);
1445
+ const inlineContent = await parseInlineFormatting(m.text, instance, issueKey);
1318
1446
  items.push({
1319
1447
  type: "listItem",
1320
1448
  content: [{ type: "paragraph", content: inlineContent }],
@@ -1328,7 +1456,7 @@ async function parseListBlock(lines, startIdx, baseIndent, instance) {
1328
1456
  }
1329
1457
 
1330
1458
  // Parse text with markdown formatting and @mentions, build ADF content
1331
- async function buildCommentADF(text, instance = defaultInstance) {
1459
+ async function buildCommentADF(text, instance = defaultInstance, issueKey = null) {
1332
1460
  // Sanitize: replace em dashes and en dashes with hyphen
1333
1461
  text = text.replace(/[—–]/g, "-");
1334
1462
 
@@ -1377,7 +1505,7 @@ async function buildCommentADF(text, instance = defaultInstance) {
1377
1505
  if (headingMatch) {
1378
1506
  const level = headingMatch[1].length;
1379
1507
  const headingText = headingMatch[2];
1380
- const inlineContent = await parseInlineFormatting(headingText, instance);
1508
+ const inlineContent = await parseInlineFormatting(headingText, instance, issueKey);
1381
1509
  content.push({
1382
1510
  type: "heading",
1383
1511
  attrs: { level },
@@ -1394,7 +1522,7 @@ async function buildCommentADF(text, instance = defaultInstance) {
1394
1522
  quoteLines.push(lines[i].trim().substring(2));
1395
1523
  i++;
1396
1524
  }
1397
- const quoteContent = await parseInlineFormatting(quoteLines.join("\n"), instance);
1525
+ const quoteContent = await parseInlineFormatting(quoteLines.join("\n"), instance, issueKey);
1398
1526
  content.push({
1399
1527
  type: "blockquote",
1400
1528
  content: [{ type: "paragraph", content: quoteContent }],
@@ -1426,7 +1554,7 @@ async function buildCommentADF(text, instance = defaultInstance) {
1426
1554
  const cellType = r === 0 && isHeader ? "tableHeader" : "tableCell";
1427
1555
  const adfCells = [];
1428
1556
  for (const cellText of tableRows[r]) {
1429
- const inlineContent = await parseInlineFormatting(cellText, instance);
1557
+ const inlineContent = await parseInlineFormatting(cellText, instance, issueKey);
1430
1558
  adfCells.push({
1431
1559
  type: cellType,
1432
1560
  content: [{ type: "paragraph", content: inlineContent }],
@@ -1447,6 +1575,7 @@ async function buildCommentADF(text, instance = defaultInstance) {
1447
1575
  i,
1448
1576
  listStart.indent,
1449
1577
  instance,
1578
+ issueKey,
1450
1579
  );
1451
1580
  content.push(node);
1452
1581
  i = nextIndex;
@@ -1468,7 +1597,7 @@ async function buildCommentADF(text, instance = defaultInstance) {
1468
1597
  !(lines[i].trim().startsWith("|") && lines[i].trim().endsWith("|"))
1469
1598
  ) {
1470
1599
  if (paragraphContent.length > 0) paragraphContent.push({ type: "hardBreak" });
1471
- const inlineNodes = await parseInlineFormatting(lines[i].trim(), instance);
1600
+ const inlineNodes = await parseInlineFormatting(lines[i].trim(), instance, issueKey);
1472
1601
  paragraphContent.push(...inlineNodes);
1473
1602
  i++;
1474
1603
  }
@@ -2330,7 +2459,7 @@ async function getChangelogsBulk(jql, maxResults = 50, instance = defaultInstanc
2330
2459
 
2331
2460
  const SERVER_INSTRUCTIONS = `# jira-mcp usage rules
2332
2461
 
2333
- - \`instance\` (optional, on most tools): multi-instance setups only. Omit it — it auto-detects from the issue key prefix, else uses the default instance.
2462
+ - \`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\`).
2334
2463
  - \`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.
2335
2464
  ${confluenceMode !== "always" ? "- Confluence tools are hidden until activated: call `confluence_enable` first when a task needs Confluence (pages, spaces, page comments, labels, attachments).\n" : ""}
2336
2465
  ## Writing Jira comments (jira_add_comment / jira_reply_comment / jira_edit_comment)
@@ -2342,7 +2471,7 @@ After the tool succeeds, its response echoes the posted comment as a markdown bl
2342
2471
  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.`;
2343
2472
 
2344
2473
  const server = new Server(
2345
- { name: "jira-mcp", version: "1.0.0" },
2474
+ { name: "jira-mcp", version: PKG_VERSION },
2346
2475
  { capabilities: { tools: { listChanged: true } }, instructions: SERVER_INSTRUCTIONS },
2347
2476
  );
2348
2477
 
@@ -2730,7 +2859,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2730
2859
  },
2731
2860
  setDefault: {
2732
2861
  type: "boolean",
2733
- description: "Set this instance as the default (default: false)",
2862
+ 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.",
2734
2863
  },
2735
2864
  defaultTeam: {
2736
2865
  type: "string",
@@ -2766,7 +2895,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2766
2895
  {
2767
2896
  name: "jira_list_instances",
2768
2897
  description:
2769
- "List all configured Jira instances with their names, URLs, project prefixes, and which is the default.",
2898
+ "List all configured Jira instances with their names, URLs, project prefixes, paths, and which is the session default and why (env, path, or config).",
2770
2899
  inputSchema: {
2771
2900
  type: "object",
2772
2901
  properties: {},
@@ -3848,6 +3977,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3848
3977
  {},
3849
3978
  inst,
3850
3979
  );
3980
+ } else if (!hasAssignmentContext && (!users || users.length === 0)) {
3981
+ users = await searchAssignableUsersInProjects(inst, args.query, maxResults);
3851
3982
  }
3852
3983
 
3853
3984
  if (!users || users.length === 0) {
@@ -3937,7 +4068,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3937
4068
  } else if (name === "jira_add_comment") {
3938
4069
  const inst = args.instance ? getInstanceByName(args.instance) : getInstanceForKey(args.issueKey);
3939
4070
  // Build ADF content with mention support
3940
- const adfContent = await buildCommentADF(args.comment, inst);
4071
+ const adfContent = await buildCommentADF(args.comment, inst, args.issueKey);
3941
4072
  const body = {
3942
4073
  body: {
3943
4074
  version: 1,
@@ -4032,7 +4163,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4032
4163
  } else if (name === "jira_edit_comment") {
4033
4164
  const inst = args.instance ? getInstanceByName(args.instance) : getInstanceForKey(args.issueKey);
4034
4165
  // Build ADF content with mention support
4035
- const adfContent = await buildCommentADF(args.comment, inst);
4166
+ const adfContent = await buildCommentADF(args.comment, inst, args.issueKey);
4036
4167
  const body = {
4037
4168
  body: {
4038
4169
  version: 1,
@@ -4445,10 +4576,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4445
4576
  instances.push(savedInstance);
4446
4577
  activeInstance = savedInstance;
4447
4578
  }
4448
- defaultInstance =
4449
- instances.find((instance) => instance.name === persisted.defaultInstance) ||
4450
- instances[0];
4451
4579
  rawConfig.defaultInstance = persisted.defaultInstance;
4580
+ applySessionDefault();
4452
4581
 
4453
4582
  const action = persisted.isUpdate ? "Updated" : "Added";
4454
4583
  const savedBaseUrl = activeInstance.baseUrl;
@@ -4456,7 +4585,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4456
4585
  const projectTeams = activeInstance.projectTeams || {};
4457
4586
  let text = `${action} instance "${instName}" (${savedBaseUrl}).`;
4458
4587
  if (savedProjects.length > 0) text += ` Projects: ${savedProjects.join(", ")}.`;
4459
- if (args.setDefault) text += " Set as default.";
4588
+ if (args.setDefault) {
4589
+ text += " Set as default.";
4590
+ if (defaultInstance.name !== instName) {
4591
+ text += ` This session keeps using "${defaultInstance.name}" (from ${describeSessionDefault()}).`;
4592
+ }
4593
+ }
4460
4594
  if (projectTeamChange) {
4461
4595
  const pt = projectTeams[projectTeamChange.key];
4462
4596
  if (pt === "none") {
@@ -4518,10 +4652,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4518
4652
  // Update in-memory state only after the atomic config replacement succeeds.
4519
4653
  const idx = instances.findIndex((i) => i.name === instName);
4520
4654
  if (idx >= 0) instances.splice(idx, 1);
4521
- defaultInstance =
4522
- instances.find((instance) => instance.name === removal.defaultInstance) ||
4523
- instances[0];
4524
4655
  rawConfig.defaultInstance = removal.defaultInstance;
4656
+ applySessionDefault();
4525
4657
 
4526
4658
  return { content: [{ type: "text", text: `Removed instance "${instName}".` }] };
4527
4659
 
@@ -4529,12 +4661,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4529
4661
  if (instances.length === 0) {
4530
4662
  return { content: [{ type: "text", text: "No instances configured." }] };
4531
4663
  }
4532
- const currentDefault = rawConfig.defaultInstance || instances[0].name;
4664
+ const configDefault =
4665
+ instances.find((inst) => inst.name === rawConfig.defaultInstance) || instances[0];
4533
4666
  let text = `# Configured Jira Instances (${instances.length})\n\n`;
4667
+ text += `Session default: **${defaultInstance.name}** (from ${describeSessionDefault()}). Config default: ${configDefault.name}.\n`;
4668
+ if (sessionDefault.ignoredEnv) {
4669
+ text += `JIRA_MCP_INSTANCE "${sessionDefault.ignoredEnv}" does not match a configured instance and is ignored.\n`;
4670
+ }
4671
+ text += "\n";
4534
4672
  const missingTeam = [];
4535
4673
  for (const inst of instances) {
4536
- const isDefault = inst.name === currentDefault ? " **(default)**" : "";
4674
+ let isDefault = inst.name === defaultInstance.name ? " **(default)**" : "";
4675
+ if (inst.name === configDefault.name && inst.name !== defaultInstance.name) {
4676
+ isDefault = " (config default)";
4677
+ }
4537
4678
  const projs = inst.projects?.length > 0 ? `\n Projects: ${inst.projects.join(", ")}` : "";
4679
+ const paths = Array.isArray(inst.paths) && inst.paths.length > 0 ? `\n Paths: ${inst.paths.join(", ")}` : "";
4538
4680
  const team = inst.defaultTeam === "none" ? "\n Default team: None (disabled)" : inst.defaultTeam ? `\n Default team: ${inst.defaultTeam.name}` : "";
4539
4681
  let projTeams = "";
4540
4682
  if (inst.projectTeams && Object.keys(inst.projectTeams).length > 0) {
@@ -4543,7 +4685,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4543
4685
  projTeams += `\n ${pk} team: ${ptName}`;
4544
4686
  }
4545
4687
  }
4546
- text += `- **${inst.name}**${isDefault}: ${inst.baseUrl} (${inst.email})${projs}${team}${projTeams}\n`;
4688
+ text += `- **${inst.name}**${isDefault}: ${inst.baseUrl} (${inst.email})${projs}${paths}${team}${projTeams}\n`;
4547
4689
  if (!inst.defaultTeam) missingTeam.push(inst);
4548
4690
  }
4549
4691
  if (missingTeam.length > 0) {
@@ -5758,6 +5900,7 @@ if (typeof module !== "undefined") {
5758
5900
  checkRateLimit, _setClockForTests, _resetBucketsForTests,
5759
5901
  extractRoutingPrefixes, probeJiraProject, discoverProjectInstance,
5760
5902
  persistDiscoveredProjects, resolveInstanceForTool, _injectDryRunSchema,
5903
+ resolveSessionDefault,
5761
5904
  AUDIT_LOG_PATH,
5762
5905
  };
5763
5906
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rui.branco/jira-mcp",
3
- "version": "1.7.11",
3
+ "version": "1.7.13",
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": {
package/setup.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  const readline = require("readline");
4
4
  const fs = require("fs");
5
+ const os = require("os");
5
6
  const path = require("path");
6
7
  const configStore = require("./config-store.js");
7
8
 
@@ -369,7 +370,7 @@ async function addInstance(config) {
369
370
  }
370
371
 
371
372
  function printFigmaStatus() {
372
- const figmaConfigPath = path.join(process.env.HOME, ".config/figma-mcp/config.json");
373
+ const figmaConfigPath = path.join(os.homedir(), ".config/figma-mcp/config.json");
373
374
  if (fs.existsSync(figmaConfigPath)) {
374
375
  console.log("\n[OK] Figma MCP detected - Figma links in tickets will be fetched automatically");
375
376
  } else {