@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 +39 -0
- package/config-store.js +1 -1
- package/index.js +206 -63
- package/package.json +1 -1
- package/setup.js +2 -1
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(
|
|
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
|
|
22
|
+
const { spawn } = require("child_process");
|
|
22
23
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
247
|
+
os.homedir(),
|
|
159
248
|
".config/jira-mcp/attachments",
|
|
160
249
|
);
|
|
161
250
|
const figmaExportsDir = path.join(
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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)
|
|
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
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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 {
|