@rui.branco/jira-mcp 1.7.4 → 1.7.6

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 +115 -10
  2. package/index.js +507 -30
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Jira MCP Server
2
2
 
3
- A [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that brings Jira ticket context directly into Claude Code. Fetch complete ticket information including descriptions, comments, attachments, and linked Figma designs without leaving your development environment.
3
+ A [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that brings Jira ticket context directly into any MCP-compatible AI client — Claude Code, Codex CLI, Google's Antigravity (Gemini), Cursor, Windsurf, Zed, and others. Fetch complete ticket information including descriptions, comments, attachments, and linked Figma designs without leaving your development environment.
4
4
 
5
5
  ## Overview
6
6
 
@@ -9,7 +9,7 @@ When working on development tasks, context switching between Jira and your code
9
9
  - **Fetching complete ticket context** - Get descriptions, comments, status, and metadata instantly
10
10
  - **Downloading attachments** - Image attachments are downloaded and displayed inline
11
11
  - **Auto-fetching Figma designs** - Linked Figma URLs are automatically detected and exported as images
12
- - **Enabling natural queries** - Search tickets with JQL directly from Claude Code
12
+ - **Enabling natural queries** - Search tickets with JQL directly from your AI client
13
13
 
14
14
  ## Features
15
15
 
@@ -27,20 +27,44 @@ When working on development tasks, context switching between Jira and your code
27
27
  ### Prerequisites
28
28
 
29
29
  - Node.js 18+
30
- - [Claude Code](https://claude.ai/code) CLI
30
+ - An MCP-compatible AI client (see [Step 1](#step-1-register-with-your-mcp-client) below)
31
31
  - Jira Cloud account with API access
32
32
 
33
- ### Step 1: Add to Claude Code
33
+ ### Step 1: Register with your MCP client
34
+
35
+ Pick the snippet for your client. All of them launch the server over stdio.
36
+
37
+ **Claude Code:**
34
38
 
35
39
  ```bash
36
40
  claude mcp add --transport stdio jira -- npx -y @rui.branco/jira-mcp
37
41
  ```
38
42
 
43
+ **Codex CLI:** add to `~/.codex/config.toml`:
44
+
45
+ ```toml
46
+ [mcp_servers.jira]
47
+ command = "npx"
48
+ args = ["-y", "@rui.branco/jira-mcp"]
49
+ ```
50
+
51
+ **Google Antigravity / Gemini CLI:** add to `~/.gemini/settings.json`:
52
+
53
+ ```json
54
+ {
55
+ "mcpServers": {
56
+ "jira": { "command": "npx", "args": ["-y", "@rui.branco/jira-mcp"] }
57
+ }
58
+ }
59
+ ```
60
+
61
+ **Cursor, Windsurf, Zed, Cline, Continue, etc.:** add the same `command`/`args` pair to whatever JSON config that client uses for MCP servers (the exact path varies, but the shape is standard across clients).
62
+
39
63
  ### Step 2: Get Your Jira API Token
40
64
 
41
65
  1. Go to [Atlassian API Tokens](https://id.atlassian.com/manage-profile/security/api-tokens)
42
66
  2. Click **"Create API token"**
43
- 3. Enter a label (e.g., "Claude Code MCP")
67
+ 3. Enter a label (e.g., "jira-mcp")
44
68
  4. Click **"Create"**
45
69
  5. Copy the token (you won't be able to see it again)
46
70
 
@@ -66,7 +90,7 @@ npx @rui.branco/jira-mcp setup
66
90
 
67
91
  ### Step 4: Verify
68
92
 
69
- Restart Claude Code and run `/mcp` to verify the server is connected.
93
+ Restart your AI client and check its MCP status (e.g. `/mcp` in Claude Code, `mcp` in Codex CLI, or the equivalent panel in your client) to verify the server is connected.
70
94
 
71
95
  ### Alternative: Manual Installation
72
96
 
@@ -78,7 +102,7 @@ cd ~/.config/jira-mcp && npm install
78
102
  node setup.js
79
103
  ```
80
104
 
81
- Then add to Claude Code:
105
+ Then register the local entry point with your MCP client (replace the `npx -y @rui.branco/jira-mcp` command from Step 1 with `node $HOME/.config/jira-mcp/index.js`). Example for Claude Code:
82
106
 
83
107
  ```bash
84
108
  claude mcp add --transport stdio jira -- node $HOME/.config/jira-mcp/index.js
@@ -146,11 +170,11 @@ This MCP automatically detects Figma URLs in ticket descriptions and comments. W
146
170
  - Figma links are automatically fetched
147
171
  - Large frames are split into sections for better readability
148
172
  - Images are exported at 2x scale for clarity
149
- - All images are displayed inline in Claude Code
173
+ - All images are displayed inline in clients that support image content blocks (Claude Code, Codex, Antigravity, etc.)
150
174
 
151
175
  To enable Figma integration:
152
176
  1. Install and configure [figma-mcp](https://github.com/rui-branco/figma-mcp)
153
- 2. Restart Claude Code
177
+ 2. Restart your AI client
154
178
  3. Figma links will be auto-fetched when you get a ticket
155
179
 
156
180
  ## API Reference
@@ -230,11 +254,92 @@ The server provides clear error messages:
230
254
  - Tokens are never logged or transmitted except to Jira/Figma APIs
231
255
  - Attachments are downloaded to `~/.config/jira-mcp/attachments/`
232
256
 
257
+ ## Safety & Observability
258
+
259
+ Four opt-in controls limit what an agent can do without your approval and give you a trail when something goes wrong. All four are off by default for existing installs — drop the relevant config block into `~/.config/jira-mcp/config.json` to turn them on.
260
+
261
+ ### Per-tool scopes
262
+
263
+ Restrict which tools each instance can call. Useful for "this Atlassian instance is read-only" or "this one only allows comments".
264
+
265
+ ```json
266
+ {
267
+ "instances": [
268
+ {
269
+ "name": "prod",
270
+ "email": "you@company.com",
271
+ "token": "...",
272
+ "baseUrl": "https://company.atlassian.net",
273
+ "scopes": { "preset": "read-only" }
274
+ },
275
+ {
276
+ "name": "sandbox",
277
+ "email": "you@company.com",
278
+ "token": "...",
279
+ "baseUrl": "https://sandbox.atlassian.net",
280
+ "scopes": { "preset": "no-destructive", "deny": ["jira_remove_attachment"] }
281
+ }
282
+ ]
283
+ }
284
+ ```
285
+
286
+ Presets: `read-only`, `comments-only`, `no-destructive`, `unrestricted`. You can combine a preset with `allow: [...]` and `deny: [...]` — deny wins. If `scopes` is present, an unknown tool name fails closed. If `scopes` is omitted, the instance behaves as before (no restrictions).
287
+
288
+ ### Dry-run mode
289
+
290
+ For any mutating tool call, return the HTTP request that *would* be sent without actually calling Atlassian. Useful for previewing what an agent is about to do.
291
+
292
+ Three layers of precedence (most-specific wins):
293
+
294
+ 1. **Per call:** pass `dryRun: true` to any mutating tool.
295
+ 2. **Per instance:** add `"dryRun": true` to an instance in `config.json`.
296
+ 3. **Environment:** export `JIRA_MCP_DRY_RUN=1` before launching your AI client.
297
+
298
+ Read tools ignore the flag. The response looks like:
299
+
300
+ ```json
301
+ {
302
+ "dryRun": true,
303
+ "plan": [{ "api": "jira", "method": "POST", "endpoint": "/issue/PROJ-1/comment", "body": { ... } }]
304
+ }
305
+ ```
306
+
307
+ For multi-step tools (e.g. `jira_transition` with intermediate statuses) only the first write is captured.
308
+
309
+ ### Audit log
310
+
311
+ Every write attempt — successful, failed, denied by scope, denied by rate limit, or dry-run — is appended as a JSON line to `~/.config/jira-mcp/audit.log`. Token-like fields (`token`, `authorization`, `password`, `apiKey`, `fileContent`, etc.) are redacted before they hit disk. Rotates at 10 MB, keeps 5 historical files.
312
+
313
+ Disable with `"audit": { "enabled": false }` at the top level of `config.json`.
314
+
315
+ ### Rate limiting
316
+
317
+ Layered in-memory token buckets prevent runaway loops. Defaults:
318
+
319
+ - **global:** 60 writes/minute
320
+ - **per-instance:** 30 writes/minute
321
+ - **destructive tools** (`*_delete_*`, `jira_remove_*`, `confluence_delete_*`): 5/minute
322
+
323
+ On limit hit the tool call fails immediately with a `Rate limit exceeded` error — no sleeping, no queueing. Reads bypass the limiter entirely. Dry-runs don't consume tokens.
324
+
325
+ Override or disable in `config.json`:
326
+
327
+ ```json
328
+ {
329
+ "rateLimit": {
330
+ "enabled": true,
331
+ "global": 120,
332
+ "perInstance": 60,
333
+ "destructive": 10
334
+ }
335
+ }
336
+ ```
337
+
233
338
  ## License
234
339
 
235
340
  MIT
236
341
 
237
342
  ## Related
238
343
 
239
- - [figma-mcp](https://github.com/rui-branco/figma-mcp) - Figma MCP server for Claude Code
344
+ - [figma-mcp](https://github.com/rui-branco/figma-mcp) - Figma MCP server (works with any MCP-compatible AI client)
240
345
  - [Model Context Protocol](https://modelcontextprotocol.io/) - MCP specification
package/index.js CHANGED
@@ -134,10 +134,321 @@ if (!fs.existsSync(attachmentDir)) {
134
134
  fs.mkdirSync(attachmentDir, { recursive: true });
135
135
  }
136
136
 
137
+ // ============ TOOL METADATA + POLICY ============
138
+ //
139
+ // TOOL_METADATA is the source of truth for op classification — NOT the HTTP
140
+ // method. The new Jira /search/jql endpoint is POST but semantically a read,
141
+ // so deciding "is this a write?" from the request method would wrongly trip
142
+ // audit + rate-limit on every search.
143
+
144
+ const TOOL_METADATA = {
145
+ // ---- Jira reads ----
146
+ jira_get_myself: { product: "jira", op: "read" },
147
+ jira_get_ticket: { product: "jira", op: "read" },
148
+ jira_search: { product: "jira", op: "read" },
149
+ jira_search_users: { product: "jira", op: "read" },
150
+ jira_get_changelog: { product: "jira", op: "read" },
151
+ jira_list_instances: { product: "jira", op: "read" },
152
+ jira_get_teams: { product: "jira", op: "read" },
153
+ jira_get_link_types: { product: "jira", op: "read" },
154
+ jira_get_transitions: { product: "jira", op: "read" },
155
+ jira_get_worklogs: { product: "jira", op: "read" },
156
+ jira_get_sprints: { product: "jira", op: "read" },
157
+ jira_get_boards: { product: "jira", op: "read" },
158
+ jira_get_issue_types: { product: "jira", op: "read" },
159
+ jira_get_priorities: { product: "jira", op: "read" },
160
+ jira_get_components: { product: "jira", op: "read" },
161
+ jira_get_versions: { product: "jira", op: "read" },
162
+ // ---- Jira writes (reversible) ----
163
+ jira_add_comment: { product: "jira", op: "write", category: "comment" },
164
+ jira_reply_comment: { product: "jira", op: "write", category: "comment" },
165
+ jira_edit_comment: { product: "jira", op: "write", category: "comment" },
166
+ jira_transition: { product: "jira", op: "write", category: "transition" },
167
+ jira_update_ticket: { product: "jira", op: "write", category: "ticket" },
168
+ jira_create_ticket: { product: "jira", op: "write", category: "ticket" },
169
+ jira_create_subtask: { product: "jira", op: "write", category: "ticket" },
170
+ jira_clone_ticket: { product: "jira", op: "write", category: "ticket" },
171
+ jira_link_tickets: { product: "jira", op: "write", category: "link" },
172
+ jira_unlink_tickets: { product: "jira", op: "write", category: "link" },
173
+ jira_add_attachment: { product: "jira", op: "write", category: "attachment" },
174
+ jira_add_watcher: { product: "jira", op: "write", category: "watcher" },
175
+ jira_remove_watcher: { product: "jira", op: "write", category: "watcher" },
176
+ jira_add_worklog: { product: "jira", op: "write", category: "worklog" },
177
+ jira_move_to_sprint: { product: "jira", op: "write", category: "sprint" },
178
+ jira_add_instance: { product: "jira", op: "write", category: "config" },
179
+ // ---- Jira destructive ----
180
+ jira_delete_comment: { product: "jira", op: "destructive", category: "comment" },
181
+ jira_delete_ticket: { product: "jira", op: "destructive", category: "ticket" },
182
+ jira_remove_attachment: { product: "jira", op: "destructive", category: "attachment" },
183
+ jira_remove_instance: { product: "jira", op: "destructive", category: "config" },
184
+ // ---- Confluence reads ----
185
+ confluence_get_spaces: { product: "confluence", op: "read" },
186
+ confluence_get_space: { product: "confluence", op: "read" },
187
+ confluence_search: { product: "confluence", op: "read" },
188
+ confluence_get_recent_pages: { product: "confluence", op: "read" },
189
+ confluence_get_space_root_pages: { product: "confluence", op: "read" },
190
+ confluence_get_page_children: { product: "confluence", op: "read" },
191
+ confluence_get_page: { product: "confluence", op: "read" },
192
+ confluence_get_comments: { product: "confluence", op: "read" },
193
+ confluence_get_labels: { product: "confluence", op: "read" },
194
+ confluence_list_attachments: { product: "confluence", op: "read" },
195
+ confluence_download_attachment: { product: "confluence", op: "read" },
196
+ // ---- Confluence writes ----
197
+ confluence_create_space: { product: "confluence", op: "write", category: "space" },
198
+ confluence_update_space: { product: "confluence", op: "write", category: "space" },
199
+ confluence_add_comment: { product: "confluence", op: "write", category: "comment" },
200
+ confluence_update_page: { product: "confluence", op: "write", category: "page" },
201
+ confluence_create_page: { product: "confluence", op: "write", category: "page" },
202
+ confluence_add_label: { product: "confluence", op: "write", category: "label" },
203
+ confluence_remove_label: { product: "confluence", op: "write", category: "label" },
204
+ confluence_upload_attachment: { product: "confluence", op: "write", category: "attachment" },
205
+ // ---- Confluence destructive ----
206
+ confluence_delete_space: { product: "confluence", op: "destructive", category: "space" },
207
+ confluence_delete_page: { product: "confluence", op: "destructive", category: "page" },
208
+ };
209
+
210
+ const COMMENT_TOOLS = new Set([
211
+ "jira_add_comment",
212
+ "jira_reply_comment",
213
+ "jira_edit_comment",
214
+ "confluence_add_comment",
215
+ ]);
216
+
217
+ const SCOPE_PRESETS = {
218
+ "read-only": (m) => m.op === "read",
219
+ "comments-only": (m, name) => m.op === "read" || COMMENT_TOOLS.has(name),
220
+ "no-destructive": (m) => m.op !== "destructive",
221
+ unrestricted: () => true,
222
+ };
223
+
224
+ function checkScope(toolName, instance) {
225
+ const scopes = instance && instance.scopes;
226
+ // Fail-open when instance has no scopes block — preserves existing installs.
227
+ if (!scopes) return { allowed: true };
228
+
229
+ const meta = TOOL_METADATA[toolName];
230
+ // Unknown tool with scopes configured → fail closed.
231
+ if (!meta) {
232
+ return { allowed: false, reason: `Tool '${toolName}' has no metadata; denied under scoped instance '${instance.name}'.` };
233
+ }
234
+
235
+ // Explicit deny wins.
236
+ if (Array.isArray(scopes.deny) && scopes.deny.includes(toolName)) {
237
+ return { allowed: false, reason: `Tool '${toolName}' is in instance '${instance.name}' deny list.` };
238
+ }
239
+ // Explicit allow wins after deny.
240
+ if (Array.isArray(scopes.allow) && scopes.allow.includes(toolName)) {
241
+ return { allowed: true };
242
+ }
243
+ // Preset.
244
+ if (scopes.preset) {
245
+ const presetFn = SCOPE_PRESETS[scopes.preset];
246
+ if (!presetFn) {
247
+ return { allowed: false, reason: `Unknown scope preset '${scopes.preset}' on instance '${instance.name}'.` };
248
+ }
249
+ if (presetFn(meta, toolName)) return { allowed: true };
250
+ return { allowed: false, reason: `Tool '${toolName}' (${meta.op}) not permitted by preset '${scopes.preset}' on '${instance.name}'.` };
251
+ }
252
+ // scopes block present but neither preset nor allow listed → deny.
253
+ return { allowed: false, reason: `Tool '${toolName}' not in instance '${instance.name}' allow list.` };
254
+ }
255
+
256
+ // ---- Dry-run resolution ----
257
+ const DRY_RUN_ENV =
258
+ process.env.JIRA_MCP_DRY_RUN === "1" ||
259
+ process.env.JIRA_MCP_DRY_RUN === "true";
260
+
261
+ function resolveDryRun(toolName, args, instance) {
262
+ const meta = TOOL_METADATA[toolName];
263
+ if (!meta || meta.op === "read") return false;
264
+ if (args && args.dryRun === true) return true;
265
+ if (args && args.dryRun === false) return false;
266
+ if (instance && instance.dryRun === true) return true;
267
+ return DRY_RUN_ENV;
268
+ }
269
+
270
+ // Dry-run execution context. fetchJira/fetchConfluence check this and short-
271
+ // circuit. Only the FIRST write in a chain is captured — multi-write tools
272
+ // (e.g. transition with intermediate steps) report just step 1 with a note.
273
+ const DRY_RUN_ABORT = Symbol("jira-mcp.dry-run-abort");
274
+ const _policyCtx = { dryRun: false, plan: null };
275
+
276
+ function recordDryRunCall(call) {
277
+ if (_policyCtx.plan) _policyCtx.plan.push(call);
278
+ throw DRY_RUN_ABORT;
279
+ }
280
+
281
+ // ---- Audit log ----
282
+ const AUDIT_LOG_PATH = path.join(
283
+ process.env.HOME || "/tmp",
284
+ ".config/jira-mcp/audit.log",
285
+ );
286
+ const AUDIT_LOG_MAX_BYTES = 10 * 1024 * 1024;
287
+ const AUDIT_LOG_KEEP = 5;
288
+ const SECRET_KEY_PATTERN =
289
+ /^(token|auth|authorization|password|secret|api[_-]?key|file[_-]?content)$/i;
290
+
291
+ function scrubArgs(value) {
292
+ if (value == null) return value;
293
+ if (Array.isArray(value)) return value.map(scrubArgs);
294
+ if (typeof value === "object") {
295
+ const out = {};
296
+ for (const [k, v] of Object.entries(value)) {
297
+ if (SECRET_KEY_PATTERN.test(k)) {
298
+ out[k] = "[REDACTED]";
299
+ } else if (typeof v === "string" && v.length > 4000) {
300
+ out[k] = v.slice(0, 4000) + `…[truncated ${v.length - 4000} chars]`;
301
+ } else {
302
+ out[k] = scrubArgs(v);
303
+ }
304
+ }
305
+ return out;
306
+ }
307
+ return value;
308
+ }
309
+
310
+ function rotateAuditIfNeeded() {
311
+ try {
312
+ const stat = fs.statSync(AUDIT_LOG_PATH);
313
+ if (stat.size < AUDIT_LOG_MAX_BYTES) return;
314
+ } catch {
315
+ return; // file does not exist yet
316
+ }
317
+ // Shift audit.log.N → audit.log.N+1
318
+ for (let i = AUDIT_LOG_KEEP - 1; i >= 1; i--) {
319
+ const src = `${AUDIT_LOG_PATH}.${i}`;
320
+ const dst = `${AUDIT_LOG_PATH}.${i + 1}`;
321
+ try { if (fs.existsSync(src)) fs.renameSync(src, dst); } catch {}
322
+ }
323
+ try { fs.renameSync(AUDIT_LOG_PATH, `${AUDIT_LOG_PATH}.1`); } catch {}
324
+ }
325
+
326
+ function audit(entry) {
327
+ try {
328
+ if (auditConfig.enabled === false) return;
329
+ const auditDir = path.dirname(AUDIT_LOG_PATH);
330
+ if (!fs.existsSync(auditDir)) fs.mkdirSync(auditDir, { recursive: true });
331
+ rotateAuditIfNeeded();
332
+ const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + "\n";
333
+ fs.appendFileSync(AUDIT_LOG_PATH, line);
334
+ } catch {
335
+ // Audit failures must not block real writes.
336
+ }
337
+ }
338
+
339
+ // ---- Rate limiter (token buckets) ----
340
+ let _clock = () => Date.now();
341
+ function _setClockForTests(fn) { _clock = fn; }
342
+
343
+ // Buckets are populated lazily. Defaults are conservative-but-not-annoying.
344
+ // User can override via config.rateLimit = { enabled: bool, global: N, perInstance: N, destructive: N }.
345
+ const DEFAULT_RL = { enabled: true, global: 60, perInstance: 30, destructive: 5 };
346
+ let _rlConfig = { ...DEFAULT_RL };
347
+ const _buckets = new Map();
348
+
349
+ function _bucket(key, capacity) {
350
+ let b = _buckets.get(key);
351
+ if (!b) {
352
+ b = { tokens: capacity, lastRefill: _clock(), capacity };
353
+ _buckets.set(key, b);
354
+ }
355
+ return b;
356
+ }
357
+
358
+ function _refill(b, refillPerMin) {
359
+ const now = _clock();
360
+ const elapsed = now - b.lastRefill;
361
+ if (elapsed <= 0) return;
362
+ const refill = (elapsed / 60000) * refillPerMin;
363
+ b.tokens = Math.min(b.capacity, b.tokens + refill);
364
+ b.lastRefill = now;
365
+ }
366
+
367
+ function checkRateLimit(toolName, instance) {
368
+ if (_rlConfig.enabled === false) return { allowed: true };
369
+ const meta = TOOL_METADATA[toolName];
370
+ if (!meta || meta.op === "read") return { allowed: true };
371
+
372
+ const checks = [
373
+ { key: "global", capacity: _rlConfig.global },
374
+ { key: `instance:${instance.name}`, capacity: _rlConfig.perInstance },
375
+ ];
376
+ if (meta.op === "destructive") {
377
+ checks.push({ key: "destructive", capacity: _rlConfig.destructive });
378
+ }
379
+
380
+ for (const c of checks) {
381
+ const b = _bucket(c.key, c.capacity);
382
+ _refill(b, c.capacity);
383
+ if (b.tokens < 1) {
384
+ const retryAfter = Math.ceil(((1 - b.tokens) / c.capacity) * 60);
385
+ return { allowed: false, bucket: c.key, retryAfter };
386
+ }
387
+ }
388
+ // Consume from all relevant buckets atomically.
389
+ for (const c of checks) {
390
+ const b = _bucket(c.key, c.capacity);
391
+ b.tokens -= 1;
392
+ }
393
+ return { allowed: true };
394
+ }
395
+
396
+ function _resetBucketsForTests() {
397
+ _buckets.clear();
398
+ _rlConfig = { ...DEFAULT_RL };
399
+ }
400
+
401
+ // Pick up user overrides from config.
402
+ let auditConfig = { enabled: true };
403
+ if (rawConfig.audit && typeof rawConfig.audit === "object") {
404
+ auditConfig = { ...auditConfig, ...rawConfig.audit };
405
+ }
406
+ if (rawConfig.rateLimit && typeof rawConfig.rateLimit === "object") {
407
+ _rlConfig = { ..._rlConfig, ...rawConfig.rateLimit };
408
+ }
409
+
410
+ // Target extractor for audit log.
411
+ function extractTarget(args) {
412
+ if (!args || typeof args !== "object") return undefined;
413
+ return (
414
+ args.issueKey ||
415
+ args.pageId ||
416
+ args.spaceKey ||
417
+ args.commentId ||
418
+ args.boardId ||
419
+ args.attachmentId ||
420
+ args.accountId ||
421
+ undefined
422
+ );
423
+ }
424
+
425
+ // Resolve instance for policy purposes (must run BEFORE the dispatch handler).
426
+ // Routing keys vary by tool — issueKey is most common but subtask uses
427
+ // parentKey, link uses inwardIssueKey, create_ticket uses projectKey, and
428
+ // Confluence tools route by spaceKey. If a tool genuinely targets a specific
429
+ // instance via no key (e.g. jira_get_boards), it falls back to the default
430
+ // instance — the user can still override with args.instance.
431
+ function resolveInstanceForTool(toolName, args) {
432
+ if (!args) return defaultInstance;
433
+ if (args.instance) return getInstanceByName(args.instance);
434
+ const keyLike =
435
+ args.issueKey ||
436
+ args.parentKey ||
437
+ args.inwardIssueKey ||
438
+ args.outwardIssueKey;
439
+ if (keyLike) return getInstanceForKey(keyLike);
440
+ const projectLike = args.projectKey || args.spaceKey;
441
+ if (projectLike) return getInstanceForProject(projectLike);
442
+ return defaultInstance;
443
+ }
444
+
137
445
  // ============ JIRA FUNCTIONS ============
138
446
 
139
447
  async function fetchJira(endpoint, options = {}, instance = defaultInstance) {
140
448
  const { method = "GET", body } = options;
449
+ if (_policyCtx.dryRun && method !== "GET" && method !== "HEAD") {
450
+ recordDryRunCall({ api: "jira", method, endpoint, body, instance: instance.name });
451
+ }
141
452
  const headers = {
142
453
  Authorization: `Basic ${instance.auth}`,
143
454
  Accept: "application/json",
@@ -162,6 +473,9 @@ async function fetchJira(endpoint, options = {}, instance = defaultInstance) {
162
473
 
163
474
  async function fetchJiraAgile(endpoint, options = {}, instance = defaultInstance) {
164
475
  const { method = "GET", body } = options;
476
+ if (_policyCtx.dryRun && method !== "GET" && method !== "HEAD") {
477
+ recordDryRunCall({ api: "jira-agile", method, endpoint, body, instance: instance.name });
478
+ }
165
479
  const headers = {
166
480
  Authorization: `Basic ${instance.auth}`,
167
481
  Accept: "application/json",
@@ -307,6 +621,15 @@ function getConfluenceBaseUrl(instance) {
307
621
 
308
622
  async function fetchConfluence(endpoint, options = {}, instance = defaultInstance) {
309
623
  const { method = "GET", body, rawBody, contentType, extraHeaders } = options;
624
+ if (_policyCtx.dryRun && method !== "GET" && method !== "HEAD") {
625
+ recordDryRunCall({
626
+ api: "confluence",
627
+ method,
628
+ endpoint,
629
+ body: body !== undefined ? body : rawBody !== undefined ? "<raw body>" : undefined,
630
+ instance: instance.name,
631
+ });
632
+ }
310
633
  const headers = {
311
634
  Authorization: `Basic ${instance.auth}`,
312
635
  Accept: "application/json",
@@ -1667,9 +1990,31 @@ const server = new Server(
1667
1990
  { capabilities: { tools: {} } },
1668
1991
  );
1669
1992
 
1670
- server.setRequestHandler(ListToolsRequestSchema, async () => {
1993
+ // Inject `dryRun` into the schema of any mutating tool so MCP clients with
1994
+ // strict schema validation accept it as a per-call argument.
1995
+ function _injectDryRunSchema(tool) {
1996
+ const meta = TOOL_METADATA[tool.name];
1997
+ if (!meta || meta.op === "read") return tool;
1998
+ const schema = tool.inputSchema || {};
1999
+ const props = schema.properties || {};
2000
+ if (props.dryRun) return tool;
1671
2001
  return {
1672
- tools: [
2002
+ ...tool,
2003
+ inputSchema: {
2004
+ ...schema,
2005
+ properties: {
2006
+ ...props,
2007
+ dryRun: {
2008
+ type: "boolean",
2009
+ description: "If true, return the HTTP request that would be sent (method, endpoint, body) without calling Atlassian. Multi-step tools only show the first write.",
2010
+ },
2011
+ },
2012
+ },
2013
+ };
2014
+ }
2015
+
2016
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
2017
+ const rawTools = [
1673
2018
  {
1674
2019
  name: "jira_get_myself",
1675
2020
  description:
@@ -2924,14 +3269,126 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2924
3269
  required: ["pageId", "filename"],
2925
3270
  },
2926
3271
  },
2927
- ],
2928
- };
3272
+ ];
3273
+ return { tools: rawTools.map(_injectDryRunSchema) };
2929
3274
  });
2930
3275
 
3276
+ async function runWithPolicy(name, args, handler) {
3277
+ const meta = TOOL_METADATA[name];
3278
+ const inst = resolveInstanceForTool(name, args);
3279
+
3280
+ // 1. Scope check
3281
+ const scopeResult = checkScope(name, inst);
3282
+ if (!scopeResult.allowed) {
3283
+ if (meta && meta.op !== "read") {
3284
+ audit({
3285
+ tool: name,
3286
+ instance: inst.name,
3287
+ target: extractTarget(args),
3288
+ args: scrubArgs(args),
3289
+ success: false,
3290
+ deniedBy: "scope",
3291
+ reason: scopeResult.reason,
3292
+ });
3293
+ }
3294
+ return {
3295
+ content: [{ type: "text", text: `Scope denied: ${scopeResult.reason}` }],
3296
+ isError: true,
3297
+ };
3298
+ }
3299
+
3300
+ // 2. Resolve dry-run intent
3301
+ const dryRun = resolveDryRun(name, args, inst);
3302
+
3303
+ // 3. Rate limit — real writes only (dry-runs do not consume quota)
3304
+ if (!dryRun && meta && (meta.op === "write" || meta.op === "destructive")) {
3305
+ const rl = checkRateLimit(name, inst);
3306
+ if (!rl.allowed) {
3307
+ audit({
3308
+ tool: name,
3309
+ instance: inst.name,
3310
+ target: extractTarget(args),
3311
+ args: scrubArgs(args),
3312
+ success: false,
3313
+ deniedBy: "rate-limit",
3314
+ bucket: rl.bucket,
3315
+ retryAfter: rl.retryAfter,
3316
+ });
3317
+ return {
3318
+ content: [{
3319
+ type: "text",
3320
+ text: `Rate limit exceeded (bucket: ${rl.bucket}). Retry after ${rl.retryAfter}s.`,
3321
+ }],
3322
+ isError: true,
3323
+ };
3324
+ }
3325
+ }
3326
+
3327
+ // 4. Execute under policy context
3328
+ _policyCtx.dryRun = dryRun;
3329
+ _policyCtx.plan = dryRun ? [] : null;
3330
+ try {
3331
+ const result = await handler();
3332
+ if (meta && meta.op !== "read") {
3333
+ audit({
3334
+ tool: name,
3335
+ instance: inst.name,
3336
+ target: extractTarget(args),
3337
+ args: scrubArgs(args),
3338
+ dryRun,
3339
+ success: true,
3340
+ });
3341
+ }
3342
+ return result;
3343
+ } catch (error) {
3344
+ if (error === DRY_RUN_ABORT) {
3345
+ const plan = _policyCtx.plan || [];
3346
+ audit({
3347
+ tool: name,
3348
+ instance: inst.name,
3349
+ target: extractTarget(args),
3350
+ args: scrubArgs(args),
3351
+ dryRun: true,
3352
+ success: true,
3353
+ status: "dry-run",
3354
+ plan,
3355
+ });
3356
+ return {
3357
+ content: [{
3358
+ type: "text",
3359
+ text: JSON.stringify({
3360
+ dryRun: true,
3361
+ 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.",
3362
+ plan,
3363
+ }, null, 2),
3364
+ }],
3365
+ };
3366
+ }
3367
+ if (meta && meta.op !== "read") {
3368
+ audit({
3369
+ tool: name,
3370
+ instance: inst.name,
3371
+ target: extractTarget(args),
3372
+ args: scrubArgs(args),
3373
+ dryRun,
3374
+ success: false,
3375
+ error: error.message,
3376
+ });
3377
+ }
3378
+ return {
3379
+ content: [{ type: "text", text: `Error: ${error.message}` }],
3380
+ isError: true,
3381
+ };
3382
+ } finally {
3383
+ _policyCtx.dryRun = false;
3384
+ _policyCtx.plan = null;
3385
+ }
3386
+ }
3387
+
2931
3388
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
2932
3389
  const { name, arguments: args } = request.params;
2933
3390
 
2934
- try {
3391
+ return runWithPolicy(name, args, async () => {
2935
3392
  if (name === "jira_get_myself") {
2936
3393
  const inst = getInstanceByName(args.instance);
2937
3394
  const result = await fetchJira("/myself", {}, inst);
@@ -3488,11 +3945,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3488
3945
  }
3489
3946
  }
3490
3947
 
3491
- // Save without the computed auth field
3492
- const toSave = { name: instName, email, token, baseUrl, projects };
3948
+ // Save without the computed auth field. Preserve any safety/observability
3949
+ // fields the user has configured on the existing instance (scopes, dryRun,
3950
+ // confluenceBaseUrl) so they survive a re-run of jira_add_instance.
3951
+ const savedIdx = savedConfig.instances.findIndex((i) => i.name === instName);
3952
+ const prevSaved = savedIdx >= 0 ? savedConfig.instances[savedIdx] : {};
3953
+ const toSave = {
3954
+ ...prevSaved,
3955
+ name: instName,
3956
+ email,
3957
+ token,
3958
+ baseUrl,
3959
+ projects,
3960
+ };
3493
3961
  if (defaultTeam) toSave.defaultTeam = defaultTeam;
3494
3962
  if (Object.keys(projectTeams).length > 0) toSave.projectTeams = projectTeams;
3495
- const savedIdx = savedConfig.instances.findIndex((i) => i.name === instName);
3496
3963
  if (savedIdx >= 0) {
3497
3964
  savedConfig.instances[savedIdx] = toSave;
3498
3965
  } else {
@@ -3721,20 +4188,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3721
4188
  if (args.labels) {
3722
4189
  fields.labels = args.labels;
3723
4190
  }
3724
- let teamWarning = "";
3725
- if (args.team) {
3726
- fields.customfield_10001 = await resolveTeamId(args.team, inst);
3727
- } else {
3728
- // Inherit team from parent ticket
3729
- const parentIssue = await fetchJira(
3730
- `/issue/${args.parentKey}?fields=customfield_10001`, {}, inst,
3731
- );
3732
- if (parentIssue.fields?.customfield_10001) {
3733
- fields.customfield_10001 = parentIssue.fields.customfield_10001;
3734
- } else if (inst.defaultTeam && inst.defaultTeam !== "none") {
3735
- teamWarning = `\n\nNote: Parent ${args.parentKey} has no team assigned. Instance default team is "${inst.defaultTeam.name}". Use team parameter to assign it.`;
3736
- }
3737
- }
4191
+ // Subtasks inherit team from their parent in Jira — never send customfield_10001
3738
4192
 
3739
4193
  const result = await fetchJira("/issue", { method: "POST", body: { fields } }, inst);
3740
4194
  const newKey = result.key;
@@ -3742,7 +4196,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3742
4196
  content: [
3743
4197
  {
3744
4198
  type: "text",
3745
- text: `Created subtask ${newKey} under ${args.parentKey}: ${args.summary}\nURL: ${inst.baseUrl}/browse/${newKey}${teamWarning}`,
4199
+ text: `Created subtask ${newKey} under ${args.parentKey}: ${args.summary}\nURL: ${inst.baseUrl}/browse/${newKey}`,
3746
4200
  },
3747
4201
  ],
3748
4202
  };
@@ -3841,6 +4295,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3841
4295
  Buffer.from(footer),
3842
4296
  ]);
3843
4297
 
4298
+ if (_policyCtx.dryRun) {
4299
+ recordDryRunCall({
4300
+ api: "jira",
4301
+ method: "POST",
4302
+ endpoint: `/issue/${args.issueKey}/attachments`,
4303
+ body: `<multipart upload: ${fileName}, ${fileBuffer.length} bytes>`,
4304
+ instance: inst.name,
4305
+ });
4306
+ }
4307
+
3844
4308
  const response = await fetch(
3845
4309
  `${inst.baseUrl}/rest/api/3/issue/${args.issueKey}/attachments`,
3846
4310
  {
@@ -4731,6 +5195,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4731
5195
  const bodyBuffer = Buffer.concat(parts);
4732
5196
 
4733
5197
  const url = `${getConfluenceBaseUrl(inst)}/rest/api/content/${encodeURIComponent(args.pageId)}/child/attachment`;
5198
+ if (_policyCtx.dryRun) {
5199
+ recordDryRunCall({
5200
+ api: "confluence",
5201
+ method: "POST",
5202
+ endpoint: `/rest/api/content/${args.pageId}/child/attachment`,
5203
+ body: `<multipart upload: ${fileName}, ${fileBuffer.length} bytes>`,
5204
+ instance: inst.name,
5205
+ });
5206
+ }
4734
5207
  const response = await fetch(url, {
4735
5208
  method: "POST",
4736
5209
  headers: {
@@ -4768,12 +5241,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4768
5241
  } else {
4769
5242
  throw new Error(`Unknown tool: ${name}`);
4770
5243
  }
4771
- } catch (error) {
4772
- return {
4773
- content: [{ type: "text", text: `Error: ${error.message}` }],
4774
- isError: true,
4775
- };
4776
- }
5244
+ });
4777
5245
  });
4778
5246
 
4779
5247
  async function main() {
@@ -4787,5 +5255,14 @@ if (require.main === module) {
4787
5255
 
4788
5256
  // Export for testing
4789
5257
  if (typeof module !== "undefined") {
4790
- module.exports = { buildCommentADF, parseInlineFormatting, autoLinkTextNodes, findJiraTicketKeys, resolveTeamId, fetchJiraTeams, listTeams, searchTeamsViaJql, fetchConfluence, getConfluenceBaseUrl, textToConfluenceStorage, htmlEscape };
5258
+ module.exports = {
5259
+ buildCommentADF, parseInlineFormatting, autoLinkTextNodes, findJiraTicketKeys,
5260
+ resolveTeamId, fetchJiraTeams, listTeams, searchTeamsViaJql,
5261
+ fetchConfluence, getConfluenceBaseUrl, textToConfluenceStorage, htmlEscape,
5262
+ // Policy module exports
5263
+ TOOL_METADATA, checkScope, resolveDryRun, scrubArgs, extractTarget,
5264
+ checkRateLimit, _setClockForTests, _resetBucketsForTests,
5265
+ resolveInstanceForTool, _injectDryRunSchema,
5266
+ AUDIT_LOG_PATH,
5267
+ };
4791
5268
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rui.branco/jira-mcp",
3
- "version": "1.7.4",
4
- "description": "Jira MCP server for Claude Code - fetch tickets, search with JQL, update tickets, manage comments, change status, and get Figma designs",
3
+ "version": "1.7.6",
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": {
7
7
  "jira-mcp": "./index.js"