@rui.branco/jira-mcp 1.7.5 → 1.7.7

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.
Files changed (3) hide show
  1. package/README.md +128 -10
  2. package/index.js +692 -112
  3. package/package.json +2 -2
package/index.js CHANGED
@@ -53,6 +53,15 @@ const jiraConfigPath = path.join(
53
53
  );
54
54
  const rawConfig = JSON.parse(fs.readFileSync(jiraConfigPath, "utf8"));
55
55
 
56
+ // Confluence tool visibility: 'on-demand' (default) hides the confluence_*
57
+ // tools until confluence_enable is called; 'always' exposes them from boot.
58
+ // Env JIRA_MCP_CONFLUENCE overrides the config key.
59
+ const confluenceMode = String(process.env.JIRA_MCP_CONFLUENCE || rawConfig.confluence || "on-demand").toLowerCase();
60
+ if (confluenceMode !== "always" && confluenceMode !== "on-demand") {
61
+ console.error(`jira-mcp: invalid confluence mode "${confluenceMode}", falling back to "on-demand"`);
62
+ }
63
+ let confluenceActive = confluenceMode === "always";
64
+
56
65
  // Normalize config to multi-instance format
57
66
  let instances;
58
67
  if (rawConfig.instances) {
@@ -134,10 +143,323 @@ if (!fs.existsSync(attachmentDir)) {
134
143
  fs.mkdirSync(attachmentDir, { recursive: true });
135
144
  }
136
145
 
146
+ // ============ TOOL METADATA + POLICY ============
147
+ //
148
+ // TOOL_METADATA is the source of truth for op classification — NOT the HTTP
149
+ // method. The new Jira /search/jql endpoint is POST but semantically a read,
150
+ // so deciding "is this a write?" from the request method would wrongly trip
151
+ // audit + rate-limit on every search.
152
+
153
+ const TOOL_METADATA = {
154
+ // ---- Jira reads ----
155
+ jira_get_myself: { product: "jira", op: "read" },
156
+ jira_get_ticket: { product: "jira", op: "read" },
157
+ jira_search: { product: "jira", op: "read" },
158
+ jira_search_users: { product: "jira", op: "read" },
159
+ jira_get_changelog: { product: "jira", op: "read" },
160
+ jira_list_instances: { product: "jira", op: "read" },
161
+ jira_get_teams: { product: "jira", op: "read" },
162
+ jira_get_link_types: { product: "jira", op: "read" },
163
+ jira_get_transitions: { product: "jira", op: "read" },
164
+ jira_get_worklogs: { product: "jira", op: "read" },
165
+ jira_get_sprints: { product: "jira", op: "read" },
166
+ jira_get_boards: { product: "jira", op: "read" },
167
+ jira_get_issue_types: { product: "jira", op: "read" },
168
+ jira_get_priorities: { product: "jira", op: "read" },
169
+ jira_get_components: { product: "jira", op: "read" },
170
+ jira_get_versions: { product: "jira", op: "read" },
171
+ // ---- Jira writes (reversible) ----
172
+ jira_add_comment: { product: "jira", op: "write", category: "comment" },
173
+ jira_reply_comment: { product: "jira", op: "write", category: "comment" },
174
+ jira_edit_comment: { product: "jira", op: "write", category: "comment" },
175
+ jira_transition: { product: "jira", op: "write", category: "transition" },
176
+ jira_update_ticket: { product: "jira", op: "write", category: "ticket" },
177
+ jira_create_ticket: { product: "jira", op: "write", category: "ticket" },
178
+ jira_create_subtask: { product: "jira", op: "write", category: "ticket" },
179
+ jira_clone_ticket: { product: "jira", op: "write", category: "ticket" },
180
+ jira_link_tickets: { product: "jira", op: "write", category: "link" },
181
+ jira_unlink_tickets: { product: "jira", op: "write", category: "link" },
182
+ jira_add_attachment: { product: "jira", op: "write", category: "attachment" },
183
+ jira_add_watcher: { product: "jira", op: "write", category: "watcher" },
184
+ jira_remove_watcher: { product: "jira", op: "write", category: "watcher" },
185
+ jira_add_worklog: { product: "jira", op: "write", category: "worklog" },
186
+ jira_move_to_sprint: { product: "jira", op: "write", category: "sprint" },
187
+ jira_add_instance: { product: "jira", op: "write", category: "config" },
188
+ // ---- Jira destructive ----
189
+ jira_delete_comment: { product: "jira", op: "destructive", category: "comment" },
190
+ jira_delete_ticket: { product: "jira", op: "destructive", category: "ticket" },
191
+ jira_remove_attachment: { product: "jira", op: "destructive", category: "attachment" },
192
+ jira_remove_instance: { product: "jira", op: "destructive", category: "config" },
193
+ // ---- Confluence activation ----
194
+ confluence_enable: { product: "confluence", op: "read" },
195
+ // ---- Confluence reads ----
196
+ confluence_get_spaces: { product: "confluence", op: "read" },
197
+ confluence_get_space: { product: "confluence", op: "read" },
198
+ confluence_search: { product: "confluence", op: "read" },
199
+ confluence_get_recent_pages: { product: "confluence", op: "read" },
200
+ confluence_get_space_root_pages: { product: "confluence", op: "read" },
201
+ confluence_get_page_children: { product: "confluence", op: "read" },
202
+ confluence_get_page: { product: "confluence", op: "read" },
203
+ confluence_get_comments: { product: "confluence", op: "read" },
204
+ confluence_get_labels: { product: "confluence", op: "read" },
205
+ confluence_list_attachments: { product: "confluence", op: "read" },
206
+ confluence_download_attachment: { product: "confluence", op: "read" },
207
+ // ---- Confluence writes ----
208
+ confluence_create_space: { product: "confluence", op: "write", category: "space" },
209
+ confluence_update_space: { product: "confluence", op: "write", category: "space" },
210
+ confluence_add_comment: { product: "confluence", op: "write", category: "comment" },
211
+ confluence_update_page: { product: "confluence", op: "write", category: "page" },
212
+ confluence_create_page: { product: "confluence", op: "write", category: "page" },
213
+ confluence_add_label: { product: "confluence", op: "write", category: "label" },
214
+ confluence_remove_label: { product: "confluence", op: "write", category: "label" },
215
+ confluence_upload_attachment: { product: "confluence", op: "write", category: "attachment" },
216
+ // ---- Confluence destructive ----
217
+ confluence_delete_space: { product: "confluence", op: "destructive", category: "space" },
218
+ confluence_delete_page: { product: "confluence", op: "destructive", category: "page" },
219
+ };
220
+
221
+ const COMMENT_TOOLS = new Set([
222
+ "jira_add_comment",
223
+ "jira_reply_comment",
224
+ "jira_edit_comment",
225
+ "confluence_add_comment",
226
+ ]);
227
+
228
+ const SCOPE_PRESETS = {
229
+ "read-only": (m) => m.op === "read",
230
+ "comments-only": (m, name) => m.op === "read" || COMMENT_TOOLS.has(name),
231
+ "no-destructive": (m) => m.op !== "destructive",
232
+ unrestricted: () => true,
233
+ };
234
+
235
+ function checkScope(toolName, instance) {
236
+ const scopes = instance && instance.scopes;
237
+ // Fail-open when instance has no scopes block — preserves existing installs.
238
+ if (!scopes) return { allowed: true };
239
+
240
+ const meta = TOOL_METADATA[toolName];
241
+ // Unknown tool with scopes configured → fail closed.
242
+ if (!meta) {
243
+ return { allowed: false, reason: `Tool '${toolName}' has no metadata; denied under scoped instance '${instance.name}'.` };
244
+ }
245
+
246
+ // Explicit deny wins.
247
+ if (Array.isArray(scopes.deny) && scopes.deny.includes(toolName)) {
248
+ return { allowed: false, reason: `Tool '${toolName}' is in instance '${instance.name}' deny list.` };
249
+ }
250
+ // Explicit allow wins after deny.
251
+ if (Array.isArray(scopes.allow) && scopes.allow.includes(toolName)) {
252
+ return { allowed: true };
253
+ }
254
+ // Preset.
255
+ if (scopes.preset) {
256
+ const presetFn = SCOPE_PRESETS[scopes.preset];
257
+ if (!presetFn) {
258
+ return { allowed: false, reason: `Unknown scope preset '${scopes.preset}' on instance '${instance.name}'.` };
259
+ }
260
+ if (presetFn(meta, toolName)) return { allowed: true };
261
+ return { allowed: false, reason: `Tool '${toolName}' (${meta.op}) not permitted by preset '${scopes.preset}' on '${instance.name}'.` };
262
+ }
263
+ // scopes block present but neither preset nor allow listed → deny.
264
+ return { allowed: false, reason: `Tool '${toolName}' not in instance '${instance.name}' allow list.` };
265
+ }
266
+
267
+ // ---- Dry-run resolution ----
268
+ const DRY_RUN_ENV =
269
+ process.env.JIRA_MCP_DRY_RUN === "1" ||
270
+ process.env.JIRA_MCP_DRY_RUN === "true";
271
+
272
+ function resolveDryRun(toolName, args, instance) {
273
+ const meta = TOOL_METADATA[toolName];
274
+ if (!meta || meta.op === "read") return false;
275
+ if (args && args.dryRun === true) return true;
276
+ if (args && args.dryRun === false) return false;
277
+ if (instance && instance.dryRun === true) return true;
278
+ return DRY_RUN_ENV;
279
+ }
280
+
281
+ // Dry-run execution context. fetchJira/fetchConfluence check this and short-
282
+ // circuit. Only the FIRST write in a chain is captured — multi-write tools
283
+ // (e.g. transition with intermediate steps) report just step 1 with a note.
284
+ const DRY_RUN_ABORT = Symbol("jira-mcp.dry-run-abort");
285
+ const _policyCtx = { dryRun: false, plan: null };
286
+
287
+ function recordDryRunCall(call) {
288
+ if (_policyCtx.plan) _policyCtx.plan.push(call);
289
+ throw DRY_RUN_ABORT;
290
+ }
291
+
292
+ // ---- Audit log ----
293
+ const AUDIT_LOG_PATH = path.join(
294
+ process.env.HOME || "/tmp",
295
+ ".config/jira-mcp/audit.log",
296
+ );
297
+ const AUDIT_LOG_MAX_BYTES = 10 * 1024 * 1024;
298
+ const AUDIT_LOG_KEEP = 5;
299
+ const SECRET_KEY_PATTERN =
300
+ /^(token|auth|authorization|password|secret|api[_-]?key|file[_-]?content)$/i;
301
+
302
+ function scrubArgs(value) {
303
+ if (value == null) return value;
304
+ if (Array.isArray(value)) return value.map(scrubArgs);
305
+ if (typeof value === "object") {
306
+ const out = {};
307
+ for (const [k, v] of Object.entries(value)) {
308
+ if (SECRET_KEY_PATTERN.test(k)) {
309
+ out[k] = "[REDACTED]";
310
+ } else if (typeof v === "string" && v.length > 4000) {
311
+ out[k] = v.slice(0, 4000) + `…[truncated ${v.length - 4000} chars]`;
312
+ } else {
313
+ out[k] = scrubArgs(v);
314
+ }
315
+ }
316
+ return out;
317
+ }
318
+ return value;
319
+ }
320
+
321
+ function rotateAuditIfNeeded() {
322
+ try {
323
+ const stat = fs.statSync(AUDIT_LOG_PATH);
324
+ if (stat.size < AUDIT_LOG_MAX_BYTES) return;
325
+ } catch {
326
+ return; // file does not exist yet
327
+ }
328
+ // Shift audit.log.N → audit.log.N+1
329
+ for (let i = AUDIT_LOG_KEEP - 1; i >= 1; i--) {
330
+ const src = `${AUDIT_LOG_PATH}.${i}`;
331
+ const dst = `${AUDIT_LOG_PATH}.${i + 1}`;
332
+ try { if (fs.existsSync(src)) fs.renameSync(src, dst); } catch {}
333
+ }
334
+ try { fs.renameSync(AUDIT_LOG_PATH, `${AUDIT_LOG_PATH}.1`); } catch {}
335
+ }
336
+
337
+ function audit(entry) {
338
+ try {
339
+ if (auditConfig.enabled === false) return;
340
+ const auditDir = path.dirname(AUDIT_LOG_PATH);
341
+ if (!fs.existsSync(auditDir)) fs.mkdirSync(auditDir, { recursive: true });
342
+ rotateAuditIfNeeded();
343
+ const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + "\n";
344
+ fs.appendFileSync(AUDIT_LOG_PATH, line);
345
+ } catch {
346
+ // Audit failures must not block real writes.
347
+ }
348
+ }
349
+
350
+ // ---- Rate limiter (token buckets) ----
351
+ let _clock = () => Date.now();
352
+ function _setClockForTests(fn) { _clock = fn; }
353
+
354
+ // Buckets are populated lazily. Defaults are conservative-but-not-annoying.
355
+ // User can override via config.rateLimit = { enabled: bool, global: N, perInstance: N, destructive: N }.
356
+ const DEFAULT_RL = { enabled: true, global: 60, perInstance: 30, destructive: 5 };
357
+ let _rlConfig = { ...DEFAULT_RL };
358
+ const _buckets = new Map();
359
+
360
+ function _bucket(key, capacity) {
361
+ let b = _buckets.get(key);
362
+ if (!b) {
363
+ b = { tokens: capacity, lastRefill: _clock(), capacity };
364
+ _buckets.set(key, b);
365
+ }
366
+ return b;
367
+ }
368
+
369
+ function _refill(b, refillPerMin) {
370
+ const now = _clock();
371
+ const elapsed = now - b.lastRefill;
372
+ if (elapsed <= 0) return;
373
+ const refill = (elapsed / 60000) * refillPerMin;
374
+ b.tokens = Math.min(b.capacity, b.tokens + refill);
375
+ b.lastRefill = now;
376
+ }
377
+
378
+ function checkRateLimit(toolName, instance) {
379
+ if (_rlConfig.enabled === false) return { allowed: true };
380
+ const meta = TOOL_METADATA[toolName];
381
+ if (!meta || meta.op === "read") return { allowed: true };
382
+
383
+ const checks = [
384
+ { key: "global", capacity: _rlConfig.global },
385
+ { key: `instance:${instance.name}`, capacity: _rlConfig.perInstance },
386
+ ];
387
+ if (meta.op === "destructive") {
388
+ checks.push({ key: "destructive", capacity: _rlConfig.destructive });
389
+ }
390
+
391
+ for (const c of checks) {
392
+ const b = _bucket(c.key, c.capacity);
393
+ _refill(b, c.capacity);
394
+ if (b.tokens < 1) {
395
+ const retryAfter = Math.ceil(((1 - b.tokens) / c.capacity) * 60);
396
+ return { allowed: false, bucket: c.key, retryAfter };
397
+ }
398
+ }
399
+ // Consume from all relevant buckets atomically.
400
+ for (const c of checks) {
401
+ const b = _bucket(c.key, c.capacity);
402
+ b.tokens -= 1;
403
+ }
404
+ return { allowed: true };
405
+ }
406
+
407
+ function _resetBucketsForTests() {
408
+ _buckets.clear();
409
+ _rlConfig = { ...DEFAULT_RL };
410
+ }
411
+
412
+ // Pick up user overrides from config.
413
+ let auditConfig = { enabled: true };
414
+ if (rawConfig.audit && typeof rawConfig.audit === "object") {
415
+ auditConfig = { ...auditConfig, ...rawConfig.audit };
416
+ }
417
+ if (rawConfig.rateLimit && typeof rawConfig.rateLimit === "object") {
418
+ _rlConfig = { ..._rlConfig, ...rawConfig.rateLimit };
419
+ }
420
+
421
+ // Target extractor for audit log.
422
+ function extractTarget(args) {
423
+ if (!args || typeof args !== "object") return undefined;
424
+ return (
425
+ args.issueKey ||
426
+ args.pageId ||
427
+ args.spaceKey ||
428
+ args.commentId ||
429
+ args.boardId ||
430
+ args.attachmentId ||
431
+ args.accountId ||
432
+ undefined
433
+ );
434
+ }
435
+
436
+ // Resolve instance for policy purposes (must run BEFORE the dispatch handler).
437
+ // Routing keys vary by tool — issueKey is most common but subtask uses
438
+ // parentKey, link uses inwardIssueKey, create_ticket uses projectKey, and
439
+ // Confluence tools route by spaceKey. If a tool genuinely targets a specific
440
+ // instance via no key (e.g. jira_get_boards), it falls back to the default
441
+ // instance — the user can still override with args.instance.
442
+ function resolveInstanceForTool(toolName, args) {
443
+ if (!args) return defaultInstance;
444
+ if (args.instance) return getInstanceByName(args.instance);
445
+ const keyLike =
446
+ args.issueKey ||
447
+ args.parentKey ||
448
+ args.inwardIssueKey ||
449
+ args.outwardIssueKey;
450
+ if (keyLike) return getInstanceForKey(keyLike);
451
+ const projectLike = args.projectKey || args.spaceKey;
452
+ if (projectLike) return getInstanceForProject(projectLike);
453
+ return defaultInstance;
454
+ }
455
+
137
456
  // ============ JIRA FUNCTIONS ============
138
457
 
139
458
  async function fetchJira(endpoint, options = {}, instance = defaultInstance) {
140
459
  const { method = "GET", body } = options;
460
+ if (_policyCtx.dryRun && method !== "GET" && method !== "HEAD") {
461
+ recordDryRunCall({ api: "jira", method, endpoint, body, instance: instance.name });
462
+ }
141
463
  const headers = {
142
464
  Authorization: `Basic ${instance.auth}`,
143
465
  Accept: "application/json",
@@ -162,6 +484,9 @@ async function fetchJira(endpoint, options = {}, instance = defaultInstance) {
162
484
 
163
485
  async function fetchJiraAgile(endpoint, options = {}, instance = defaultInstance) {
164
486
  const { method = "GET", body } = options;
487
+ if (_policyCtx.dryRun && method !== "GET" && method !== "HEAD") {
488
+ recordDryRunCall({ api: "jira-agile", method, endpoint, body, instance: instance.name });
489
+ }
165
490
  const headers = {
166
491
  Authorization: `Basic ${instance.auth}`,
167
492
  Accept: "application/json",
@@ -307,6 +632,15 @@ function getConfluenceBaseUrl(instance) {
307
632
 
308
633
  async function fetchConfluence(endpoint, options = {}, instance = defaultInstance) {
309
634
  const { method = "GET", body, rawBody, contentType, extraHeaders } = options;
635
+ if (_policyCtx.dryRun && method !== "GET" && method !== "HEAD") {
636
+ recordDryRunCall({
637
+ api: "confluence",
638
+ method,
639
+ endpoint,
640
+ body: body !== undefined ? body : rawBody !== undefined ? "<raw body>" : undefined,
641
+ instance: instance.name,
642
+ });
643
+ }
310
644
  const headers = {
311
645
  Authorization: `Basic ${instance.auth}`,
312
646
  Accept: "application/json",
@@ -435,7 +769,10 @@ function extractText(content, urls = []) {
435
769
  } else if (node.type === "hardBreak") {
436
770
  text += "\n";
437
771
  } else if (node.type === "mention") {
438
- text += `@${node.attrs?.text || "user"}`;
772
+ // attrs.text already includes the leading "@" (e.g. "@Rui Branco").
773
+ // Don't prepend a second one.
774
+ const mentionText = node.attrs?.text || "@user";
775
+ text += mentionText.startsWith("@") ? mentionText : `@${mentionText}`;
439
776
  } else if (node.type === "mediaGroup" || node.type === "mediaSingle") {
440
777
  text += "[image attachment]\n";
441
778
  } else if (
@@ -642,6 +979,54 @@ function autoLinkTextNodes(nodes, instance = defaultInstance) {
642
979
  return result;
643
980
  }
644
981
 
982
+ // Detect a markdown list item line. Returns {indent, ordered, text} or null.
983
+ // Bullets: -, *, + followed by a space. Ordered: "1." or "1)" followed by a space.
984
+ // The marker must be followed by whitespace so inline emphasis (*italic*) and
985
+ // horizontal rules (***) are NOT misread as list items.
986
+ function listItemMatch(line) {
987
+ const m = line.match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/);
988
+ if (!m) return null;
989
+ return { indent: m[1].length, ordered: /\d/.test(m[2]), text: m[3] };
990
+ }
991
+
992
+ // Recursively parse a (possibly nested) markdown list starting at lines[startIdx].
993
+ // baseIndent is the indentation of items at this level; more-indented items are
994
+ // folded into the previous item as a nested list. Returns {node, nextIndex}.
995
+ async function parseListBlock(lines, startIdx, baseIndent, instance) {
996
+ const items = [];
997
+ let i = startIdx;
998
+ let ordered = null;
999
+ while (i < lines.length) {
1000
+ if (!lines[i].trim()) break; // blank line ends the list
1001
+ const m = listItemMatch(lines[i]);
1002
+ if (!m) break;
1003
+ if (m.indent < baseIndent) break; // dedent: belongs to a parent list
1004
+ if (m.indent > baseIndent) {
1005
+ // Deeper indent: nest under the previous item at this level.
1006
+ const sub = await parseListBlock(lines, i, m.indent, instance);
1007
+ if (items.length > 0) {
1008
+ items[items.length - 1].content.push(sub.node);
1009
+ } else {
1010
+ items.push({ type: "listItem", content: [sub.node] });
1011
+ }
1012
+ i = sub.nextIndex;
1013
+ continue;
1014
+ }
1015
+ if (ordered === null) ordered = m.ordered; // list type set by first item
1016
+ else if (m.ordered !== ordered) break; // marker switched: caller starts a new list
1017
+ const inlineContent = await parseInlineFormatting(m.text, instance);
1018
+ items.push({
1019
+ type: "listItem",
1020
+ content: [{ type: "paragraph", content: inlineContent }],
1021
+ });
1022
+ i++;
1023
+ }
1024
+ return {
1025
+ node: { type: ordered ? "orderedList" : "bulletList", content: items },
1026
+ nextIndex: i,
1027
+ };
1028
+ }
1029
+
645
1030
  // Parse text with markdown formatting and @mentions, build ADF content
646
1031
  async function buildCommentADF(text, instance = defaultInstance) {
647
1032
  // Sanitize: replace em dashes and en dashes with hyphen
@@ -754,35 +1139,17 @@ async function buildCommentADF(text, instance = defaultInstance) {
754
1139
  continue;
755
1140
  }
756
1141
 
757
- // --- Bullet list (- items) ---
758
- if (trimmed.startsWith("- ")) {
759
- const listItems = [];
760
- while (i < lines.length && lines[i].trim().startsWith("- ")) {
761
- const itemText = lines[i].trim().substring(2);
762
- const inlineContent = await parseInlineFormatting(itemText, instance);
763
- listItems.push({
764
- type: "listItem",
765
- content: [{ type: "paragraph", content: inlineContent }],
766
- });
767
- i++;
768
- }
769
- content.push({ type: "bulletList", content: listItems });
770
- continue;
771
- }
772
-
773
- // --- Ordered list (1. items) ---
774
- if (/^\d+\.\s/.test(trimmed)) {
775
- const listItems = [];
776
- while (i < lines.length && /^\d+\.\s/.test(lines[i].trim())) {
777
- const itemText = lines[i].trim().replace(/^\d+\.\s/, "");
778
- const inlineContent = await parseInlineFormatting(itemText, instance);
779
- listItems.push({
780
- type: "listItem",
781
- content: [{ type: "paragraph", content: inlineContent }],
782
- });
783
- i++;
784
- }
785
- content.push({ type: "orderedList", content: listItems });
1142
+ // --- Lists (bullet -,*,+ or ordered 1./1)) with nesting ---
1143
+ const listStart = listItemMatch(line);
1144
+ if (listStart) {
1145
+ const { node, nextIndex } = await parseListBlock(
1146
+ lines,
1147
+ i,
1148
+ listStart.indent,
1149
+ instance,
1150
+ );
1151
+ content.push(node);
1152
+ i = nextIndex;
786
1153
  continue;
787
1154
  }
788
1155
 
@@ -792,13 +1159,12 @@ async function buildCommentADF(text, instance = defaultInstance) {
792
1159
  i < lines.length &&
793
1160
  lines[i].trim() &&
794
1161
  !lines[i].trim().startsWith("```") &&
795
- !lines[i].trim().startsWith("# ") &&
796
- !lines[i].trim().startsWith("## ") &&
797
- !lines[i].trim().startsWith("### ") &&
1162
+ !/^#{1,6}\s/.test(lines[i].trim()) &&
798
1163
  !lines[i].trim().startsWith("> ") &&
799
- !lines[i].trim().startsWith("- ") &&
800
- !/^\d+\.\s/.test(lines[i].trim()) &&
1164
+ !listItemMatch(lines[i]) &&
801
1165
  !/^-{3,}$/.test(lines[i].trim()) &&
1166
+ !/^\*{3,}$/.test(lines[i].trim()) &&
1167
+ !/^_{3,}$/.test(lines[i].trim()) &&
802
1168
  !(lines[i].trim().startsWith("|") && lines[i].trim().endsWith("|"))
803
1169
  ) {
804
1170
  if (paragraphContent.length > 0) paragraphContent.push({ type: "hardBreak" });
@@ -1662,14 +2028,50 @@ async function getChangelogsBulk(jql, maxResults = 50, instance = defaultInstanc
1662
2028
 
1663
2029
  // ============ MCP SERVER ============
1664
2030
 
2031
+ const SERVER_INSTRUCTIONS = `# jira-mcp usage rules
2032
+
2033
+ - \`instance\` (optional, on most tools): multi-instance setups only. Omit it — it auto-detects from the issue key prefix, else uses the default instance.
2034
+ - \`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.
2035
+ ${confluenceMode !== "always" ? "- Confluence tools are hidden until activated: call `confluence_enable` first when a task needs Confluence (pages, spaces, page comments, labels, attachments).\n" : ""}
2036
+ ## Writing Jira comments (jira_add_comment / jira_reply_comment / jira_edit_comment)
2037
+ Write one short conversational paragraph: start with @DisplayName of who to notify (NOT [~accountId:...] syntax), say what changed, and end with the next step (e.g. 'Ready for verification.'). Keep it HIGH LEVEL for stakeholders/PO/QA — WHAT changed and the impact, not how it was built. Include technical detail (endpoints, payloads, API contracts) ONLY when the reader is a developer and it makes sense; otherwise leave out code, file paths, branch names, commit hashes, and framework-internal terms. NEVER use em dashes (—) or en dashes (–) — use commas, periods, or rewrite. Avoid bullet lists, headings, and bold unless the update genuinely needs them.
2038
+
2039
+ ## Writing descriptions (jira_create_ticket / jira_update_ticket / jira_create_subtask)
2040
+ 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.`;
2041
+
1665
2042
  const server = new Server(
1666
2043
  { name: "jira-mcp", version: "1.0.0" },
1667
- { capabilities: { tools: {} } },
2044
+ { capabilities: { tools: { listChanged: true } }, instructions: SERVER_INSTRUCTIONS },
1668
2045
  );
1669
2046
 
1670
- server.setRequestHandler(ListToolsRequestSchema, async () => {
2047
+ // Inject `dryRun` into the schema of any mutating tool so MCP clients with
2048
+ // strict schema validation accept it as a per-call argument.
2049
+ function _injectDryRunSchema(tool) {
2050
+ const meta = TOOL_METADATA[tool.name];
2051
+ if (!meta || meta.op === "read") return tool;
2052
+ const schema = tool.inputSchema || {};
2053
+ const props = schema.properties || {};
2054
+ if (props.dryRun) return tool;
1671
2055
  return {
1672
- tools: [
2056
+ ...tool,
2057
+ inputSchema: {
2058
+ ...schema,
2059
+ properties: {
2060
+ ...props,
2061
+ dryRun: {
2062
+ type: "boolean",
2063
+ description: "Preview: return the would-be HTTP request without executing.",
2064
+ },
2065
+ },
2066
+ },
2067
+ };
2068
+ }
2069
+
2070
+ // Additive formatting guidance moved to SERVER_INSTRUCTIONS to reduce per-tool
2071
+ // token cost. Field descriptions retain their short base text only.
2072
+
2073
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
2074
+ const rawTools = [
1673
2075
  {
1674
2076
  name: "jira_get_myself",
1675
2077
  description:
@@ -1679,7 +2081,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1679
2081
  properties: {
1680
2082
  instance: {
1681
2083
  type: "string",
1682
- description: "Instance name (for multi-instance setups). Uses default instance if omitted.",
2084
+ description: "Instance name (optional).",
1683
2085
  },
1684
2086
  },
1685
2087
  required: [],
@@ -1707,7 +2109,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1707
2109
  },
1708
2110
  instance: {
1709
2111
  type: "string",
1710
- description: "Instance name override. Auto-detected from issue key prefix if omitted.",
2112
+ description: "Instance name (optional).",
1711
2113
  },
1712
2114
  },
1713
2115
  required: ["issueKey"],
@@ -1733,7 +2135,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1733
2135
  },
1734
2136
  instance: {
1735
2137
  type: "string",
1736
- description: "Instance name (for multi-instance setups). Uses default instance if omitted.",
2138
+ description: "Instance name (optional).",
1737
2139
  },
1738
2140
  },
1739
2141
  required: ["jql"],
@@ -1742,7 +2144,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1742
2144
  {
1743
2145
  name: "jira_add_comment",
1744
2146
  description:
1745
- "Add a comment to a Jira ticket. IMPORTANT RULES: (1) Write comments at a HIGH LEVEL for stakeholders, product owners, and managers. Focus on WHAT was done and the business impact, NOT how it was done. Example: 'Implemented the new filtering feature for the dashboard' instead of 'Added a useEffect hook with debounced API calls to filter endpoint'. (2) NEVER mention technical details: no code, no function names, no file paths, no git branches, no commit hashes, no 'pushed to main', no framework-specific terms (React, Angular, hooks, components, etc.). (3) Use @DisplayName (e.g. @Julia Pereszta) for mentions, NOT [~accountId:...] syntax. (4) NEVER use em dashes (—) or en dashes (–), use commas, periods, or rewrite the sentence instead.",
2147
+ "Add a comment to a Jira ticket. Follow the server instructions' comment style rules.",
1746
2148
  inputSchema: {
1747
2149
  type: "object",
1748
2150
  properties: {
@@ -1750,10 +2152,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1750
2152
  type: "string",
1751
2153
  description: "The Jira issue key (e.g., MODS-123)",
1752
2154
  },
1753
- comment: { type: "string", description: "The comment text to add" },
2155
+ comment: {
2156
+ type: "string",
2157
+ description: "The comment text to add.",
2158
+ },
1754
2159
  instance: {
1755
2160
  type: "string",
1756
- description: "Instance name override. Auto-detected from issue key prefix if omitted.",
2161
+ description: "Instance name (optional).",
1757
2162
  },
1758
2163
  },
1759
2164
  required: ["issueKey", "comment"],
@@ -1775,10 +2180,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1775
2180
  description:
1776
2181
  "The ID of the comment to reply to. Use jira_get_ticket to see comments and their IDs.",
1777
2182
  },
1778
- reply: { type: "string", description: "The reply text" },
2183
+ reply: {
2184
+ type: "string",
2185
+ description: "The reply text.",
2186
+ },
1779
2187
  instance: {
1780
2188
  type: "string",
1781
- description: "Instance name override. Auto-detected from issue key prefix if omitted.",
2189
+ description: "Instance name (optional).",
1782
2190
  },
1783
2191
  },
1784
2192
  required: ["issueKey", "commentId", "reply"],
@@ -1787,7 +2195,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1787
2195
  {
1788
2196
  name: "jira_edit_comment",
1789
2197
  description:
1790
- "Edit an existing comment on a Jira ticket. Replaces the comment text. IMPORTANT: Use @DisplayName (e.g. @Julia Pereszta) for mentions — NOT [~accountId:...] syntax. Keep comments non-technical and user-facing. Never mention git details like 'pushed to main', branch names, or technical implementation details.",
2198
+ "Edit an existing comment on a Jira ticket (replaces the text). Follow the server instructions' comment style rules.",
1791
2199
  inputSchema: {
1792
2200
  type: "object",
1793
2201
  properties: {
@@ -1800,10 +2208,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1800
2208
  description:
1801
2209
  "The ID of the comment to edit. Use jira_get_ticket to see comments and their IDs.",
1802
2210
  },
1803
- comment: { type: "string", description: "The new comment text" },
2211
+ comment: {
2212
+ type: "string",
2213
+ description: "The new comment text.",
2214
+ },
1804
2215
  instance: {
1805
2216
  type: "string",
1806
- description: "Instance name override. Auto-detected from issue key prefix if omitted.",
2217
+ description: "Instance name (optional).",
1807
2218
  },
1808
2219
  },
1809
2220
  required: ["issueKey", "commentId", "comment"],
@@ -1827,7 +2238,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1827
2238
  },
1828
2239
  instance: {
1829
2240
  type: "string",
1830
- description: "Instance name override. Auto-detected from issue key prefix if omitted.",
2241
+ description: "Instance name (optional).",
1831
2242
  },
1832
2243
  },
1833
2244
  required: ["issueKey", "commentId"],
@@ -1856,7 +2267,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1856
2267
  },
1857
2268
  instance: {
1858
2269
  type: "string",
1859
- description: "Instance name override. Auto-detected from issue key prefix if omitted.",
2270
+ description: "Instance name (optional).",
1860
2271
  },
1861
2272
  },
1862
2273
  required: ["issueKey"],
@@ -1917,7 +2328,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1917
2328
  },
1918
2329
  instance: {
1919
2330
  type: "string",
1920
- description: "Instance name override. Auto-detected from issue key prefix if omitted.",
2331
+ description: "Instance name (optional).",
1921
2332
  },
1922
2333
  },
1923
2334
  required: ["issueKey"],
@@ -1941,7 +2352,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1941
2352
  },
1942
2353
  instance: {
1943
2354
  type: "string",
1944
- description: "Instance name (for multi-instance setups). Uses default instance if omitted.",
2355
+ description: "Instance name (optional).",
1945
2356
  },
1946
2357
  },
1947
2358
  required: ["query"],
@@ -1971,7 +2382,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1971
2382
  },
1972
2383
  instance: {
1973
2384
  type: "string",
1974
- description: "Instance name (for multi-instance setups with jql). Auto-detected from issueKey if provided.",
2385
+ description: "Instance name (optional).",
1975
2386
  },
1976
2387
  },
1977
2388
  required: [],
@@ -2063,7 +2474,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2063
2474
  },
2064
2475
  instance: {
2065
2476
  type: "string",
2066
- description: "Instance name. Uses default instance if omitted.",
2477
+ description: "Instance name (optional).",
2067
2478
  },
2068
2479
  },
2069
2480
  required: [],
@@ -2090,7 +2501,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2090
2501
  },
2091
2502
  description: {
2092
2503
  type: "string",
2093
- description: "The issue description text. Supports @mentions via @DisplayName.",
2504
+ description:
2505
+ "The issue description text. Supports @mentions via @DisplayName.",
2094
2506
  },
2095
2507
  assignee: {
2096
2508
  type: "string",
@@ -2133,7 +2545,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2133
2545
  },
2134
2546
  instance: {
2135
2547
  type: "string",
2136
- description: "Instance name override. Auto-detected from project key if omitted.",
2548
+ description: "Instance name (optional).",
2137
2549
  },
2138
2550
  },
2139
2551
  required: ["projectKey", "issueType", "summary"],
@@ -2156,7 +2568,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2156
2568
  },
2157
2569
  description: {
2158
2570
  type: "string",
2159
- description: "The subtask description text. Supports @mentions via @DisplayName.",
2571
+ description:
2572
+ "The subtask description text. Supports @mentions via @DisplayName.",
2160
2573
  },
2161
2574
  assignee: {
2162
2575
  type: "string",
@@ -2177,7 +2590,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2177
2590
  },
2178
2591
  instance: {
2179
2592
  type: "string",
2180
- description: "Instance name override. Auto-detected from parent issue key prefix if omitted.",
2593
+ description: "Instance name (optional).",
2181
2594
  },
2182
2595
  },
2183
2596
  required: ["parentKey", "summary"],
@@ -2208,7 +2621,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2208
2621
  },
2209
2622
  instance: {
2210
2623
  type: "string",
2211
- description: "Instance name override. Auto-detected from inward issue key prefix if omitted.",
2624
+ description: "Instance name (optional).",
2212
2625
  },
2213
2626
  },
2214
2627
  required: ["inwardIssueKey", "outwardIssueKey", "linkType"],
@@ -2231,7 +2644,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2231
2644
  },
2232
2645
  instance: {
2233
2646
  type: "string",
2234
- description: "Instance name override. Auto-detected from issue key prefix if omitted.",
2647
+ description: "Instance name (optional).",
2235
2648
  },
2236
2649
  },
2237
2650
  required: ["issueKey"],
@@ -2262,7 +2675,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2262
2675
  },
2263
2676
  instance: {
2264
2677
  type: "string",
2265
- description: "Instance name override. Auto-detected from issue key prefix if omitted.",
2678
+ description: "Instance name (optional).",
2266
2679
  },
2267
2680
  },
2268
2681
  required: ["issueKey"],
@@ -2277,7 +2690,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2277
2690
  properties: {
2278
2691
  instance: {
2279
2692
  type: "string",
2280
- description: "Instance name (for multi-instance setups). Uses default instance if omitted.",
2693
+ description: "Instance name (optional).",
2281
2694
  },
2282
2695
  },
2283
2696
  required: [],
@@ -2296,7 +2709,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2296
2709
  },
2297
2710
  instance: {
2298
2711
  type: "string",
2299
- description: "Instance name override. Auto-detected from issue key prefix if omitted.",
2712
+ description: "Instance name (optional).",
2300
2713
  },
2301
2714
  },
2302
2715
  required: ["issueKey"],
@@ -2319,7 +2732,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2319
2732
  },
2320
2733
  instance: {
2321
2734
  type: "string",
2322
- description: "Instance name override. Auto-detected from issue key prefix if omitted.",
2735
+ description: "Instance name (optional).",
2323
2736
  },
2324
2737
  },
2325
2738
  required: ["issueKey", "linkedIssueKey"],
@@ -2346,7 +2759,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2346
2759
  },
2347
2760
  instance: {
2348
2761
  type: "string",
2349
- description: "Instance name override. Auto-detected from issue key prefix if omitted.",
2762
+ description: "Instance name (optional).",
2350
2763
  },
2351
2764
  },
2352
2765
  required: [],
@@ -2369,7 +2782,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2369
2782
  },
2370
2783
  instance: {
2371
2784
  type: "string",
2372
- description: "Instance name override. Auto-detected from issue key prefix if omitted.",
2785
+ description: "Instance name (optional).",
2373
2786
  },
2374
2787
  },
2375
2788
  required: ["issueKey", "accountId"],
@@ -2392,7 +2805,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2392
2805
  },
2393
2806
  instance: {
2394
2807
  type: "string",
2395
- description: "Instance name override. Auto-detected from issue key prefix if omitted.",
2808
+ description: "Instance name (optional).",
2396
2809
  },
2397
2810
  },
2398
2811
  required: ["issueKey", "accountId"],
@@ -2423,7 +2836,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2423
2836
  },
2424
2837
  instance: {
2425
2838
  type: "string",
2426
- description: "Instance name override. Auto-detected from issue key prefix if omitted.",
2839
+ description: "Instance name (optional).",
2427
2840
  },
2428
2841
  },
2429
2842
  required: ["issueKey", "timeSpent"],
@@ -2442,7 +2855,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2442
2855
  },
2443
2856
  instance: {
2444
2857
  type: "string",
2445
- description: "Instance name override. Auto-detected from issue key prefix if omitted.",
2858
+ description: "Instance name (optional).",
2446
2859
  },
2447
2860
  },
2448
2861
  required: ["issueKey"],
@@ -2473,7 +2886,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2473
2886
  },
2474
2887
  instance: {
2475
2888
  type: "string",
2476
- description: "Instance name override. Auto-detected from issue key prefix if omitted.",
2889
+ description: "Instance name (optional).",
2477
2890
  },
2478
2891
  },
2479
2892
  required: ["issueKey"],
@@ -2497,7 +2910,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2497
2910
  },
2498
2911
  instance: {
2499
2912
  type: "string",
2500
- description: "Instance name (for multi-instance setups). Uses default instance if omitted.",
2913
+ description: "Instance name (optional).",
2501
2914
  },
2502
2915
  },
2503
2916
  required: ["sprintId", "issueKeys"],
@@ -2520,7 +2933,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2520
2933
  },
2521
2934
  instance: {
2522
2935
  type: "string",
2523
- description: "Instance name (for multi-instance setups). Uses default instance if omitted.",
2936
+ description: "Instance name (optional).",
2524
2937
  },
2525
2938
  },
2526
2939
  required: ["boardId"],
@@ -2547,7 +2960,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2547
2960
  },
2548
2961
  instance: {
2549
2962
  type: "string",
2550
- description: "Instance name (for multi-instance setups). Uses default instance if omitted.",
2963
+ description: "Instance name (optional).",
2551
2964
  },
2552
2965
  },
2553
2966
  required: [],
@@ -2566,7 +2979,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2566
2979
  },
2567
2980
  instance: {
2568
2981
  type: "string",
2569
- description: "Instance name override. Auto-detected from project key if omitted.",
2982
+ description: "Instance name (optional).",
2570
2983
  },
2571
2984
  },
2572
2985
  required: ["projectKey"],
@@ -2581,7 +2994,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2581
2994
  properties: {
2582
2995
  instance: {
2583
2996
  type: "string",
2584
- description: "Instance name (for multi-instance setups). Uses default instance if omitted.",
2997
+ description: "Instance name (optional).",
2585
2998
  },
2586
2999
  },
2587
3000
  required: [],
@@ -2600,7 +3013,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2600
3013
  },
2601
3014
  instance: {
2602
3015
  type: "string",
2603
- description: "Instance name override. Auto-detected from project key if omitted.",
3016
+ description: "Instance name (optional).",
2604
3017
  },
2605
3018
  },
2606
3019
  required: ["projectKey"],
@@ -2619,7 +3032,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2619
3032
  },
2620
3033
  instance: {
2621
3034
  type: "string",
2622
- description: "Instance name override. Auto-detected from project key if omitted.",
3035
+ description: "Instance name (optional).",
2623
3036
  },
2624
3037
  },
2625
3038
  required: ["projectKey"],
@@ -2634,7 +3047,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2634
3047
  properties: {
2635
3048
  instance: {
2636
3049
  type: "string",
2637
- description: "Instance name. Uses default instance if omitted.",
3050
+ description: "Instance name (optional).",
2638
3051
  },
2639
3052
  },
2640
3053
  required: [],
@@ -2648,7 +3061,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2648
3061
  type: "object",
2649
3062
  properties: {
2650
3063
  spaceKey: { type: "string", description: "Space key (e.g., 'ENG')." },
2651
- instance: { type: "string", description: "Instance name. Uses default instance if omitted." },
3064
+ instance: { type: "string", description: "Instance name (optional)." },
2652
3065
  },
2653
3066
  required: ["spaceKey"],
2654
3067
  },
@@ -2663,7 +3076,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2663
3076
  spaceKey: { type: "string", description: "Unique space key (letters/numbers). Will be uppercased." },
2664
3077
  name: { type: "string", description: "Human-readable space name." },
2665
3078
  description: { type: "string", description: "Optional plain-text description." },
2666
- instance: { type: "string", description: "Instance name. Uses default instance if omitted." },
3079
+ instance: { type: "string", description: "Instance name (optional)." },
2667
3080
  },
2668
3081
  required: ["spaceKey", "name"],
2669
3082
  },
@@ -2678,7 +3091,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2678
3091
  spaceKey: { type: "string", description: "Space key to update." },
2679
3092
  name: { type: "string", description: "New space name." },
2680
3093
  description: { type: "string", description: "New plain-text description." },
2681
- instance: { type: "string", description: "Instance name. Uses default instance if omitted." },
3094
+ instance: { type: "string", description: "Instance name (optional)." },
2682
3095
  },
2683
3096
  required: ["spaceKey"],
2684
3097
  },
@@ -2691,7 +3104,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2691
3104
  type: "object",
2692
3105
  properties: {
2693
3106
  spaceKey: { type: "string", description: "Space key to delete." },
2694
- instance: { type: "string", description: "Instance name. Uses default instance if omitted." },
3107
+ instance: { type: "string", description: "Instance name (optional)." },
2695
3108
  },
2696
3109
  required: ["spaceKey"],
2697
3110
  },
@@ -2707,7 +3120,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2707
3120
  spaceKey: { type: "string", description: "Optional space key to scope the search." },
2708
3121
  type: { type: "string", description: "Content type (default: page). e.g. page, blogpost, comment, attachment." },
2709
3122
  limit: { type: "number", description: "Max results (default: 25)." },
2710
- instance: { type: "string", description: "Instance name. Uses default instance if omitted." },
3123
+ instance: { type: "string", description: "Instance name (optional)." },
2711
3124
  },
2712
3125
  required: ["query"],
2713
3126
  },
@@ -2722,7 +3135,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2722
3135
  spaceKey: { type: "string", description: "Optional space key to scope the query." },
2723
3136
  sinceDays: { type: "number", description: "Days back from now (default: 30)." },
2724
3137
  limit: { type: "number", description: "Max results (default: 25)." },
2725
- instance: { type: "string", description: "Instance name. Uses default instance if omitted." },
3138
+ instance: { type: "string", description: "Instance name (optional)." },
2726
3139
  },
2727
3140
  required: [],
2728
3141
  },
@@ -2736,7 +3149,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2736
3149
  properties: {
2737
3150
  spaceKey: { type: "string", description: "Space key (required)." },
2738
3151
  limit: { type: "number", description: "Max results (default: 50)." },
2739
- instance: { type: "string", description: "Instance name. Uses default instance if omitted." },
3152
+ instance: { type: "string", description: "Instance name (optional)." },
2740
3153
  },
2741
3154
  required: ["spaceKey"],
2742
3155
  },
@@ -2750,7 +3163,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2750
3163
  properties: {
2751
3164
  pageId: { type: "string", description: "Parent page ID (required)." },
2752
3165
  limit: { type: "number", description: "Max results (default: 50)." },
2753
- instance: { type: "string", description: "Instance name. Uses default instance if omitted." },
3166
+ instance: { type: "string", description: "Instance name (optional)." },
2754
3167
  },
2755
3168
  required: ["pageId"],
2756
3169
  },
@@ -2764,7 +3177,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2764
3177
  properties: {
2765
3178
  pageId: { type: "string", description: "Page ID (required)." },
2766
3179
  includeAdf: { type: "boolean", description: "Fetch ADF body via v2 API and merge (default: true)." },
2767
- instance: { type: "string", description: "Instance name. Uses default instance if omitted." },
3180
+ instance: { type: "string", description: "Instance name (optional)." },
2768
3181
  },
2769
3182
  required: ["pageId"],
2770
3183
  },
@@ -2778,7 +3191,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2778
3191
  properties: {
2779
3192
  pageId: { type: "string", description: "Page ID (required)." },
2780
3193
  limit: { type: "number", description: "Max results (default: 50)." },
2781
- instance: { type: "string", description: "Instance name. Uses default instance if omitted." },
3194
+ instance: { type: "string", description: "Instance name (optional)." },
2782
3195
  },
2783
3196
  required: ["pageId"],
2784
3197
  },
@@ -2793,7 +3206,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2793
3206
  pageId: { type: "string", description: "Page ID (required)." },
2794
3207
  body: { type: "string", description: "Comment body (plain text by default, or storage XHTML if format=storage)." },
2795
3208
  format: { type: "string", description: "'text' (default) or 'storage'." },
2796
- instance: { type: "string", description: "Instance name. Uses default instance if omitted." },
3209
+ instance: { type: "string", description: "Instance name (optional)." },
2797
3210
  },
2798
3211
  required: ["pageId", "body"],
2799
3212
  },
@@ -2809,7 +3222,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2809
3222
  title: { type: "string", description: "New page title (optional — retains current if omitted)." },
2810
3223
  body: { type: "string", description: "New body as storage XHTML (optional — retains current if omitted)." },
2811
3224
  version: { type: "number", description: "Explicit new version number. If omitted, current+1 is computed." },
2812
- instance: { type: "string", description: "Instance name. Uses default instance if omitted." },
3225
+ instance: { type: "string", description: "Instance name (optional)." },
2813
3226
  },
2814
3227
  required: ["pageId"],
2815
3228
  },
@@ -2825,7 +3238,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2825
3238
  title: { type: "string", description: "Page title (required)." },
2826
3239
  body: { type: "string", description: "Storage-format XHTML body (required)." },
2827
3240
  parentId: { type: "string", description: "Optional parent page ID to nest under." },
2828
- instance: { type: "string", description: "Instance name. Uses default instance if omitted." },
3241
+ instance: { type: "string", description: "Instance name (optional)." },
2829
3242
  },
2830
3243
  required: ["spaceKey", "title", "body"],
2831
3244
  },
@@ -2837,7 +3250,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2837
3250
  type: "object",
2838
3251
  properties: {
2839
3252
  pageId: { type: "string", description: "Page ID (required)." },
2840
- instance: { type: "string", description: "Instance name. Uses default instance if omitted." },
3253
+ instance: { type: "string", description: "Instance name (optional)." },
2841
3254
  },
2842
3255
  required: ["pageId"],
2843
3256
  },
@@ -2849,7 +3262,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2849
3262
  type: "object",
2850
3263
  properties: {
2851
3264
  pageId: { type: "string", description: "Page ID (required)." },
2852
- instance: { type: "string", description: "Instance name. Uses default instance if omitted." },
3265
+ instance: { type: "string", description: "Instance name (optional)." },
2853
3266
  },
2854
3267
  required: ["pageId"],
2855
3268
  },
@@ -2862,7 +3275,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2862
3275
  properties: {
2863
3276
  pageId: { type: "string", description: "Page ID (required)." },
2864
3277
  label: { type: "string", description: "Label name (required)." },
2865
- instance: { type: "string", description: "Instance name. Uses default instance if omitted." },
3278
+ instance: { type: "string", description: "Instance name (optional)." },
2866
3279
  },
2867
3280
  required: ["pageId", "label"],
2868
3281
  },
@@ -2875,7 +3288,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2875
3288
  properties: {
2876
3289
  pageId: { type: "string", description: "Page ID (required)." },
2877
3290
  label: { type: "string", description: "Label name (required)." },
2878
- instance: { type: "string", description: "Instance name. Uses default instance if omitted." },
3291
+ instance: { type: "string", description: "Instance name (optional)." },
2879
3292
  },
2880
3293
  required: ["pageId", "label"],
2881
3294
  },
@@ -2888,7 +3301,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2888
3301
  properties: {
2889
3302
  pageId: { type: "string", description: "Page ID (required)." },
2890
3303
  limit: { type: "number", description: "Max results (default: 50)." },
2891
- instance: { type: "string", description: "Instance name. Uses default instance if omitted." },
3304
+ instance: { type: "string", description: "Instance name (optional)." },
2892
3305
  },
2893
3306
  required: ["pageId"],
2894
3307
  },
@@ -2905,7 +3318,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2905
3318
  fileContent: { type: "string", description: "Base64-encoded file content (requires fileName)." },
2906
3319
  fileName: { type: "string", description: "File name (required with fileContent; optional with filePath)." },
2907
3320
  comment: { type: "string", description: "Optional attachment comment." },
2908
- instance: { type: "string", description: "Instance name. Uses default instance if omitted." },
3321
+ instance: { type: "string", description: "Instance name (optional)." },
2909
3322
  },
2910
3323
  required: ["pageId"],
2911
3324
  },
@@ -2919,19 +3332,153 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2919
3332
  properties: {
2920
3333
  pageId: { type: "string", description: "Page ID (required)." },
2921
3334
  filename: { type: "string", description: "Attachment filename (required)." },
2922
- instance: { type: "string", description: "Instance name. Uses default instance if omitted." },
3335
+ instance: { type: "string", description: "Instance name (optional)." },
2923
3336
  },
2924
3337
  required: ["pageId", "filename"],
2925
3338
  },
2926
3339
  },
2927
- ],
2928
- };
3340
+ ];
3341
+ let tools = rawTools;
3342
+ if (!confluenceActive) {
3343
+ tools = rawTools.filter(t => !t.name.startsWith("confluence_"));
3344
+ tools.push({
3345
+ name: "confluence_enable",
3346
+ description: "Activate the Confluence tool set (pages, spaces, page comments, labels, attachments, search). Call this first when a task needs Confluence; the confluence_* tools then become available. Activation only changes tool visibility for this server process; per-instance scopes still apply to every Confluence operation.",
3347
+ inputSchema: { type: "object", properties: {}, required: [] },
3348
+ });
3349
+ }
3350
+ return { tools: tools.map(_injectDryRunSchema) };
2929
3351
  });
2930
3352
 
3353
+ async function runWithPolicy(name, args, handler) {
3354
+ const meta = TOOL_METADATA[name];
3355
+ const inst = resolveInstanceForTool(name, args);
3356
+
3357
+ // 1. Scope check
3358
+ const scopeResult = checkScope(name, inst);
3359
+ if (!scopeResult.allowed) {
3360
+ if (meta && meta.op !== "read") {
3361
+ audit({
3362
+ tool: name,
3363
+ instance: inst.name,
3364
+ target: extractTarget(args),
3365
+ args: scrubArgs(args),
3366
+ success: false,
3367
+ deniedBy: "scope",
3368
+ reason: scopeResult.reason,
3369
+ });
3370
+ }
3371
+ return {
3372
+ content: [{ type: "text", text: `Scope denied: ${scopeResult.reason}` }],
3373
+ isError: true,
3374
+ };
3375
+ }
3376
+
3377
+ // 2. Resolve dry-run intent
3378
+ const dryRun = resolveDryRun(name, args, inst);
3379
+
3380
+ // 3. Rate limit — real writes only (dry-runs do not consume quota)
3381
+ if (!dryRun && meta && (meta.op === "write" || meta.op === "destructive")) {
3382
+ const rl = checkRateLimit(name, inst);
3383
+ if (!rl.allowed) {
3384
+ audit({
3385
+ tool: name,
3386
+ instance: inst.name,
3387
+ target: extractTarget(args),
3388
+ args: scrubArgs(args),
3389
+ success: false,
3390
+ deniedBy: "rate-limit",
3391
+ bucket: rl.bucket,
3392
+ retryAfter: rl.retryAfter,
3393
+ });
3394
+ return {
3395
+ content: [{
3396
+ type: "text",
3397
+ text: `Rate limit exceeded (bucket: ${rl.bucket}). Retry after ${rl.retryAfter}s.`,
3398
+ }],
3399
+ isError: true,
3400
+ };
3401
+ }
3402
+ }
3403
+
3404
+ // 4. Execute under policy context
3405
+ _policyCtx.dryRun = dryRun;
3406
+ _policyCtx.plan = dryRun ? [] : null;
3407
+ try {
3408
+ const result = await handler();
3409
+ if (meta && meta.op !== "read") {
3410
+ audit({
3411
+ tool: name,
3412
+ instance: inst.name,
3413
+ target: extractTarget(args),
3414
+ args: scrubArgs(args),
3415
+ dryRun,
3416
+ success: true,
3417
+ });
3418
+ }
3419
+ return result;
3420
+ } catch (error) {
3421
+ if (error === DRY_RUN_ABORT) {
3422
+ const plan = _policyCtx.plan || [];
3423
+ audit({
3424
+ tool: name,
3425
+ instance: inst.name,
3426
+ target: extractTarget(args),
3427
+ args: scrubArgs(args),
3428
+ dryRun: true,
3429
+ success: true,
3430
+ status: "dry-run",
3431
+ plan,
3432
+ });
3433
+ return {
3434
+ content: [{
3435
+ type: "text",
3436
+ text: JSON.stringify({
3437
+ dryRun: true,
3438
+ note: "Plan shows the first HTTP write only. Multi-step tools (e.g. jira_transition with intermediate steps) execute the remaining writes when run for real.",
3439
+ plan,
3440
+ }, null, 2),
3441
+ }],
3442
+ };
3443
+ }
3444
+ if (meta && meta.op !== "read") {
3445
+ audit({
3446
+ tool: name,
3447
+ instance: inst.name,
3448
+ target: extractTarget(args),
3449
+ args: scrubArgs(args),
3450
+ dryRun,
3451
+ success: false,
3452
+ error: error.message,
3453
+ });
3454
+ }
3455
+ return {
3456
+ content: [{ type: "text", text: `Error: ${error.message}` }],
3457
+ isError: true,
3458
+ };
3459
+ } finally {
3460
+ _policyCtx.dryRun = false;
3461
+ _policyCtx.plan = null;
3462
+ }
3463
+ }
3464
+
2931
3465
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
2932
3466
  const { name, arguments: args } = request.params;
2933
3467
 
2934
- try {
3468
+ if (name === "confluence_enable") {
3469
+ const wasActive = confluenceActive;
3470
+ confluenceActive = true;
3471
+ if (!wasActive) server.sendToolListChanged().catch((e) => console.error("jira-mcp: tools/list_changed notification failed:", e?.message || e));
3472
+ return { content: [{ type: "text", text: wasActive ? "Confluence tools were already active." : "Confluence tools activated — the confluence_* tools are now available." }] };
3473
+ }
3474
+ // A client with a cached tool list may call a confluence_* tool directly
3475
+ // while hidden — auto-activate instead of failing.
3476
+ if (!confluenceActive && TOOL_METADATA[name]?.product === "confluence" && name !== "confluence_enable") {
3477
+ confluenceActive = true;
3478
+ server.sendToolListChanged().catch((e) => console.error("jira-mcp: tools/list_changed notification failed:", e?.message || e));
3479
+ }
3480
+
3481
+ return runWithPolicy(name, args, async () => {
2935
3482
  if (name === "jira_get_myself") {
2936
3483
  const inst = getInstanceByName(args.instance);
2937
3484
  const result = await fetchJira("/myself", {}, inst);
@@ -3488,11 +4035,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3488
4035
  }
3489
4036
  }
3490
4037
 
3491
- // Save without the computed auth field
3492
- const toSave = { name: instName, email, token, baseUrl, projects };
4038
+ // Save without the computed auth field. Preserve any safety/observability
4039
+ // fields the user has configured on the existing instance (scopes, dryRun,
4040
+ // confluenceBaseUrl) so they survive a re-run of jira_add_instance.
4041
+ const savedIdx = savedConfig.instances.findIndex((i) => i.name === instName);
4042
+ const prevSaved = savedIdx >= 0 ? savedConfig.instances[savedIdx] : {};
4043
+ const toSave = {
4044
+ ...prevSaved,
4045
+ name: instName,
4046
+ email,
4047
+ token,
4048
+ baseUrl,
4049
+ projects,
4050
+ };
3493
4051
  if (defaultTeam) toSave.defaultTeam = defaultTeam;
3494
4052
  if (Object.keys(projectTeams).length > 0) toSave.projectTeams = projectTeams;
3495
- const savedIdx = savedConfig.instances.findIndex((i) => i.name === instName);
3496
4053
  if (savedIdx >= 0) {
3497
4054
  savedConfig.instances[savedIdx] = toSave;
3498
4055
  } else {
@@ -3828,6 +4385,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3828
4385
  Buffer.from(footer),
3829
4386
  ]);
3830
4387
 
4388
+ if (_policyCtx.dryRun) {
4389
+ recordDryRunCall({
4390
+ api: "jira",
4391
+ method: "POST",
4392
+ endpoint: `/issue/${args.issueKey}/attachments`,
4393
+ body: `<multipart upload: ${fileName}, ${fileBuffer.length} bytes>`,
4394
+ instance: inst.name,
4395
+ });
4396
+ }
4397
+
3831
4398
  const response = await fetch(
3832
4399
  `${inst.baseUrl}/rest/api/3/issue/${args.issueKey}/attachments`,
3833
4400
  {
@@ -4718,6 +5285,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4718
5285
  const bodyBuffer = Buffer.concat(parts);
4719
5286
 
4720
5287
  const url = `${getConfluenceBaseUrl(inst)}/rest/api/content/${encodeURIComponent(args.pageId)}/child/attachment`;
5288
+ if (_policyCtx.dryRun) {
5289
+ recordDryRunCall({
5290
+ api: "confluence",
5291
+ method: "POST",
5292
+ endpoint: `/rest/api/content/${args.pageId}/child/attachment`,
5293
+ body: `<multipart upload: ${fileName}, ${fileBuffer.length} bytes>`,
5294
+ instance: inst.name,
5295
+ });
5296
+ }
4721
5297
  const response = await fetch(url, {
4722
5298
  method: "POST",
4723
5299
  headers: {
@@ -4755,12 +5331,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4755
5331
  } else {
4756
5332
  throw new Error(`Unknown tool: ${name}`);
4757
5333
  }
4758
- } catch (error) {
4759
- return {
4760
- content: [{ type: "text", text: `Error: ${error.message}` }],
4761
- isError: true,
4762
- };
4763
- }
5334
+ });
4764
5335
  });
4765
5336
 
4766
5337
  async function main() {
@@ -4774,5 +5345,14 @@ if (require.main === module) {
4774
5345
 
4775
5346
  // Export for testing
4776
5347
  if (typeof module !== "undefined") {
4777
- module.exports = { buildCommentADF, parseInlineFormatting, autoLinkTextNodes, findJiraTicketKeys, resolveTeamId, fetchJiraTeams, listTeams, searchTeamsViaJql, fetchConfluence, getConfluenceBaseUrl, textToConfluenceStorage, htmlEscape };
5348
+ module.exports = {
5349
+ buildCommentADF, parseInlineFormatting, autoLinkTextNodes, findJiraTicketKeys,
5350
+ resolveTeamId, fetchJiraTeams, listTeams, searchTeamsViaJql,
5351
+ fetchConfluence, getConfluenceBaseUrl, textToConfluenceStorage, htmlEscape,
5352
+ // Policy module exports
5353
+ TOOL_METADATA, checkScope, resolveDryRun, scrubArgs, extractTarget,
5354
+ checkRateLimit, _setClockForTests, _resetBucketsForTests,
5355
+ resolveInstanceForTool, _injectDryRunSchema,
5356
+ AUDIT_LOG_PATH,
5357
+ };
4778
5358
  }