@rui.branco/jira-mcp 1.7.3 → 1.7.4

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 +32 -0
  2. package/index.js +891 -1
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -169,6 +169,38 @@ To enable Figma integration:
169
169
  | `jira_transition` | Change ticket status by name or ID (auto-handles intermediate steps) | `issueKey` (required), `targetStatus` or `transitionId` |
170
170
  | `jira_update_ticket` | Update ticket fields (summary, description, assignee, priority, labels) | `issueKey` (required), plus optional field parameters |
171
171
 
172
+ ### Confluence Tools
173
+
174
+ This MCP also exposes Confluence via the same Atlassian credentials. On Atlassian Cloud, the API token you configured for Jira works for Confluence too, and the Confluence base URL is derived as `<jiraBaseUrl>/wiki` — **no extra setup required**. Just use the `confluence_*` tools below against any configured instance.
175
+
176
+ For Server / Data Center deployments where Confluence lives on a different host, set `confluenceBaseUrl` explicitly on the stored instance in `config.json`.
177
+
178
+ | Tool | Description | Parameters |
179
+ |------|-------------|------------|
180
+ | `confluence_get_spaces` | List global spaces (`key`, `name`, `id`) | `instance` |
181
+ | `confluence_get_space` | Get a single space with description and homepage | `spaceKey` (required), `instance` |
182
+ | `confluence_create_space` | Create a new global space | `spaceKey` (required), `name` (required), `description`, `instance` |
183
+ | `confluence_update_space` | Update a space's name and/or description | `spaceKey` (required), `name`, `description`, `instance` |
184
+ | `confluence_delete_space` | Delete a space (async on Cloud) | `spaceKey` (required), `instance` |
185
+ | `confluence_search` | CQL text search (`text ~ "<query>" AND type = <type>`) | `query` (required), `spaceKey`, `type`, `limit`, `instance` |
186
+ | `confluence_get_recent_pages` | Pages modified in the last N days | `sinceDays` (default 30), `spaceKey`, `limit`, `instance` |
187
+ | `confluence_get_space_root_pages` | Top-level pages in a space with `childTypes.page` | `spaceKey` (required), `limit`, `instance` |
188
+ | `confluence_get_page_children` | Direct child pages of a page | `pageId` (required), `limit`, `instance` |
189
+ | `confluence_get_page` | Full page with `body.view`, `body.storage` and (by default) v2 `body.atlas_doc_format` merged in | `pageId` (required), `includeAdf`, `instance` |
190
+ | `confluence_get_comments` | List comments on a page | `pageId` (required), `limit`, `instance` |
191
+ | `confluence_add_comment` | Add a footer comment (plain text wrapped into storage XHTML by default) | `pageId` (required), `body` (required), `format` (`text`/`storage`), `instance` |
192
+ | `confluence_update_page` | Update title/body; auto-bumps version if not supplied | `pageId` (required), `title`, `body` (storage XHTML), `version`, `instance` |
193
+ | `confluence_create_page` | Create a page with optional parent | `spaceKey`, `title`, `body` (storage XHTML) (all required), `parentId`, `instance` |
194
+ | `confluence_delete_page` | Delete a page | `pageId` (required), `instance` |
195
+ | `confluence_get_labels` | List labels on a page | `pageId` (required), `instance` |
196
+ | `confluence_add_label` | Add a label to a page | `pageId` (required), `label` (required), `instance` |
197
+ | `confluence_remove_label` | Remove a label from a page | `pageId` (required), `label` (required), `instance` |
198
+ | `confluence_list_attachments` | List attachments on a page | `pageId` (required), `limit`, `instance` |
199
+ | `confluence_upload_attachment` | Upload a file from disk or base64 content | `pageId` (required), `filePath` or `fileContent`+`fileName`, `comment`, `instance` |
200
+ | `confluence_download_attachment` | Download an attachment by filename to the local attachment dir | `pageId` (required), `filename` (required), `instance` |
201
+
202
+ **Why ADF for `confluence_get_page`?** The v1 `body.atlas_doc_format` expand is unreliable on modern Cloud pages — table cell background colors and some newer node types only survive the v2 `/api/v2/pages/<id>?body-format=atlas_doc_format` endpoint. `confluence_get_page` therefore does both requests in parallel and merges the v2 ADF body onto the v1 response, so callers get `body.view`, `body.storage` and `body.atlas_doc_format` in one shot.
203
+
172
204
  ### Configuration
173
205
 
174
206
  Config stored at `~/.config/jira-mcp/config.json`:
package/index.js CHANGED
@@ -289,6 +289,120 @@ async function downloadAttachment(url, filename, issueKey, instance) {
289
289
  return localPath;
290
290
  }
291
291
 
292
+ // ============ CONFLUENCE FUNCTIONS ============
293
+
294
+ const confluenceAttachmentDir = path.join(
295
+ process.env.HOME,
296
+ ".config/jira-mcp/confluence-attachments",
297
+ );
298
+ if (!fs.existsSync(confluenceAttachmentDir)) {
299
+ fs.mkdirSync(confluenceAttachmentDir, { recursive: true });
300
+ }
301
+
302
+ function getConfluenceBaseUrl(instance) {
303
+ // Confluence Cloud lives at <jiraBaseUrl>/wiki. For Server/DC users can
304
+ // still override by setting confluenceBaseUrl explicitly in their config.
305
+ return instance.confluenceBaseUrl || `${instance.baseUrl}/wiki`;
306
+ }
307
+
308
+ async function fetchConfluence(endpoint, options = {}, instance = defaultInstance) {
309
+ const { method = "GET", body, rawBody, contentType, extraHeaders } = options;
310
+ const headers = {
311
+ Authorization: `Basic ${instance.auth}`,
312
+ Accept: "application/json",
313
+ };
314
+ if (rawBody !== undefined) {
315
+ if (contentType) headers["Content-Type"] = contentType;
316
+ } else if (body !== undefined) {
317
+ headers["Content-Type"] = "application/json";
318
+ }
319
+ if (extraHeaders) Object.assign(headers, extraHeaders);
320
+
321
+ const url = `${getConfluenceBaseUrl(instance)}${endpoint}`;
322
+ const response = await fetch(url, {
323
+ method,
324
+ headers,
325
+ body: rawBody !== undefined ? rawBody : body ? JSON.stringify(body) : undefined,
326
+ });
327
+ const text = await response.text();
328
+ if (!response.ok) {
329
+ let detail = "";
330
+ if (text) {
331
+ try {
332
+ const parsed = JSON.parse(text);
333
+ if (parsed.message) detail = parsed.message;
334
+ else if (Array.isArray(parsed.errorMessages) && parsed.errorMessages[0]) detail = parsed.errorMessages[0];
335
+ else if (parsed.data && parsed.data.errors && parsed.data.errors[0]) detail = parsed.data.errors[0].message || JSON.stringify(parsed.data.errors[0]);
336
+ else detail = text;
337
+ } catch {
338
+ detail = text;
339
+ }
340
+ }
341
+ throw new Error(
342
+ `Confluence API error: ${response.status} ${response.statusText}${detail ? ` - ${detail}` : ""}`,
343
+ );
344
+ }
345
+ return text ? JSON.parse(text) : {};
346
+ }
347
+
348
+ function htmlEscape(s) {
349
+ return String(s)
350
+ .replace(/&/g, "&amp;")
351
+ .replace(/</g, "&lt;")
352
+ .replace(/>/g, "&gt;");
353
+ }
354
+
355
+ function textToConfluenceStorage(text) {
356
+ // Split on blank lines, wrap each paragraph in <p>, single newlines become <br/>, escape &<>.
357
+ const paragraphs = String(text).split(/\n\s*\n/);
358
+ return paragraphs
359
+ .map((p) => p.trim())
360
+ .filter((p) => p.length > 0)
361
+ .map((p) => `<p>${htmlEscape(p).replace(/\n/g, "<br/>")}</p>`)
362
+ .join("");
363
+ }
364
+
365
+ async function downloadConfluenceAttachment(pageId, filename, instance) {
366
+ const pageDir = path.join(confluenceAttachmentDir, pageId);
367
+ if (!fs.existsSync(pageDir)) fs.mkdirSync(pageDir, { recursive: true });
368
+ const localPath = path.join(pageDir, filename);
369
+ if (fs.existsSync(localPath)) return localPath;
370
+
371
+ // Find the attachment by filename
372
+ const list = await fetchConfluence(
373
+ `/rest/api/content/${encodeURIComponent(pageId)}/child/attachment?limit=200&filename=${encodeURIComponent(filename)}`,
374
+ {},
375
+ instance,
376
+ );
377
+ let att = (list.results || []).find((a) => a.title === filename);
378
+ if (!att) {
379
+ // Fallback: list everything and match
380
+ const all = await fetchConfluence(
381
+ `/rest/api/content/${encodeURIComponent(pageId)}/child/attachment?limit=200`,
382
+ {},
383
+ instance,
384
+ );
385
+ att = (all.results || []).find((a) => a.title === filename);
386
+ }
387
+ if (!att) {
388
+ throw new Error(`Attachment "${filename}" not found on page ${pageId}.`);
389
+ }
390
+ const downloadPath = att._links && att._links.download;
391
+ const url = downloadPath
392
+ ? `${getConfluenceBaseUrl(instance)}${downloadPath}`
393
+ : `${getConfluenceBaseUrl(instance)}/rest/api/content/${encodeURIComponent(att.id)}/data`;
394
+
395
+ const response = await fetch(url, {
396
+ headers: { Authorization: `Basic ${instance.auth}` },
397
+ });
398
+ if (!response.ok) {
399
+ throw new Error(`Failed to download ${filename}: ${response.status}`);
400
+ }
401
+ const buffer = await response.buffer();
402
+ fs.writeFileSync(localPath, buffer);
403
+ return localPath;
404
+ }
405
+
292
406
  function extractText(content, urls = []) {
293
407
  if (!content) return { text: "", urls };
294
408
  if (typeof content === "string") return { text: content, urls };
@@ -2511,6 +2625,305 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2511
2625
  required: ["projectKey"],
2512
2626
  },
2513
2627
  },
2628
+ {
2629
+ name: "confluence_get_spaces",
2630
+ description:
2631
+ "List Confluence global spaces. Returns [{ key, name, id }]. On Cloud reuses the Jira instance credentials (Confluence base URL is <jiraBaseUrl>/wiki).",
2632
+ inputSchema: {
2633
+ type: "object",
2634
+ properties: {
2635
+ instance: {
2636
+ type: "string",
2637
+ description: "Instance name. Uses default instance if omitted.",
2638
+ },
2639
+ },
2640
+ required: [],
2641
+ },
2642
+ },
2643
+ {
2644
+ name: "confluence_get_space",
2645
+ description:
2646
+ "Get a single Confluence space by key. Returns space metadata including description and homepage.",
2647
+ inputSchema: {
2648
+ type: "object",
2649
+ properties: {
2650
+ spaceKey: { type: "string", description: "Space key (e.g., 'ENG')." },
2651
+ instance: { type: "string", description: "Instance name. Uses default instance if omitted." },
2652
+ },
2653
+ required: ["spaceKey"],
2654
+ },
2655
+ },
2656
+ {
2657
+ name: "confluence_create_space",
2658
+ description:
2659
+ "Create a new Confluence global space. Provide key, name, and optional description (plain text).",
2660
+ inputSchema: {
2661
+ type: "object",
2662
+ properties: {
2663
+ spaceKey: { type: "string", description: "Unique space key (letters/numbers). Will be uppercased." },
2664
+ name: { type: "string", description: "Human-readable space name." },
2665
+ description: { type: "string", description: "Optional plain-text description." },
2666
+ instance: { type: "string", description: "Instance name. Uses default instance if omitted." },
2667
+ },
2668
+ required: ["spaceKey", "name"],
2669
+ },
2670
+ },
2671
+ {
2672
+ name: "confluence_update_space",
2673
+ description:
2674
+ "Update a Confluence space's name or description. At least one of name/description must be provided.",
2675
+ inputSchema: {
2676
+ type: "object",
2677
+ properties: {
2678
+ spaceKey: { type: "string", description: "Space key to update." },
2679
+ name: { type: "string", description: "New space name." },
2680
+ description: { type: "string", description: "New plain-text description." },
2681
+ instance: { type: "string", description: "Instance name. Uses default instance if omitted." },
2682
+ },
2683
+ required: ["spaceKey"],
2684
+ },
2685
+ },
2686
+ {
2687
+ name: "confluence_delete_space",
2688
+ description:
2689
+ "Delete a Confluence space by key. This is asynchronous on Confluence Cloud — the call returns a long-running task.",
2690
+ inputSchema: {
2691
+ type: "object",
2692
+ properties: {
2693
+ spaceKey: { type: "string", description: "Space key to delete." },
2694
+ instance: { type: "string", description: "Instance name. Uses default instance if omitted." },
2695
+ },
2696
+ required: ["spaceKey"],
2697
+ },
2698
+ },
2699
+ {
2700
+ name: "confluence_search",
2701
+ description:
2702
+ "Confluence CQL search for content. Builds CQL: text ~ \"<query>\" AND type = <type> [AND space = \"<key>\"] ORDER BY lastModified DESC.",
2703
+ inputSchema: {
2704
+ type: "object",
2705
+ properties: {
2706
+ query: { type: "string", description: "Free-text search term." },
2707
+ spaceKey: { type: "string", description: "Optional space key to scope the search." },
2708
+ type: { type: "string", description: "Content type (default: page). e.g. page, blogpost, comment, attachment." },
2709
+ limit: { type: "number", description: "Max results (default: 25)." },
2710
+ instance: { type: "string", description: "Instance name. Uses default instance if omitted." },
2711
+ },
2712
+ required: ["query"],
2713
+ },
2714
+ },
2715
+ {
2716
+ name: "confluence_get_recent_pages",
2717
+ description:
2718
+ "List recently modified pages within a time window. Params: spaceKey (optional), sinceDays (default 30), limit (default 25).",
2719
+ inputSchema: {
2720
+ type: "object",
2721
+ properties: {
2722
+ spaceKey: { type: "string", description: "Optional space key to scope the query." },
2723
+ sinceDays: { type: "number", description: "Days back from now (default: 30)." },
2724
+ limit: { type: "number", description: "Max results (default: 25)." },
2725
+ instance: { type: "string", description: "Instance name. Uses default instance if omitted." },
2726
+ },
2727
+ required: [],
2728
+ },
2729
+ },
2730
+ {
2731
+ name: "confluence_get_space_root_pages",
2732
+ description:
2733
+ "List top-level (root) pages in a Confluence space, expanded with childTypes.page so consumers can tell which pages have children.",
2734
+ inputSchema: {
2735
+ type: "object",
2736
+ properties: {
2737
+ spaceKey: { type: "string", description: "Space key (required)." },
2738
+ limit: { type: "number", description: "Max results (default: 50)." },
2739
+ instance: { type: "string", description: "Instance name. Uses default instance if omitted." },
2740
+ },
2741
+ required: ["spaceKey"],
2742
+ },
2743
+ },
2744
+ {
2745
+ name: "confluence_get_page_children",
2746
+ description:
2747
+ "List direct child pages of a Confluence page, expanded with version, history and childTypes.page.",
2748
+ inputSchema: {
2749
+ type: "object",
2750
+ properties: {
2751
+ pageId: { type: "string", description: "Parent page ID (required)." },
2752
+ limit: { type: "number", description: "Max results (default: 50)." },
2753
+ instance: { type: "string", description: "Instance name. Uses default instance if omitted." },
2754
+ },
2755
+ required: ["pageId"],
2756
+ },
2757
+ },
2758
+ {
2759
+ name: "confluence_get_page",
2760
+ description:
2761
+ "Fetch a full Confluence page. Returns v1 content (body.view, body.storage, space, ancestors, labels) merged with v2 body.atlas_doc_format when includeAdf is true. ADF is the only format that preserves table cell background colors on modern Cloud pages.",
2762
+ inputSchema: {
2763
+ type: "object",
2764
+ properties: {
2765
+ pageId: { type: "string", description: "Page ID (required)." },
2766
+ 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." },
2768
+ },
2769
+ required: ["pageId"],
2770
+ },
2771
+ },
2772
+ {
2773
+ name: "confluence_get_comments",
2774
+ description:
2775
+ "List comments on a Confluence page, expanded with body.view, version, created/updated history.",
2776
+ inputSchema: {
2777
+ type: "object",
2778
+ properties: {
2779
+ pageId: { type: "string", description: "Page ID (required)." },
2780
+ limit: { type: "number", description: "Max results (default: 50)." },
2781
+ instance: { type: "string", description: "Instance name. Uses default instance if omitted." },
2782
+ },
2783
+ required: ["pageId"],
2784
+ },
2785
+ },
2786
+ {
2787
+ name: "confluence_add_comment",
2788
+ description:
2789
+ "Add a footer comment to a Confluence page. Default format is 'text' (plain text / markdown-lite is wrapped in <p>…</p> with <br/> for single newlines). Use format='storage' to pass valid storage XHTML as-is.",
2790
+ inputSchema: {
2791
+ type: "object",
2792
+ properties: {
2793
+ pageId: { type: "string", description: "Page ID (required)." },
2794
+ body: { type: "string", description: "Comment body (plain text by default, or storage XHTML if format=storage)." },
2795
+ format: { type: "string", description: "'text' (default) or 'storage'." },
2796
+ instance: { type: "string", description: "Instance name. Uses default instance if omitted." },
2797
+ },
2798
+ required: ["pageId", "body"],
2799
+ },
2800
+ },
2801
+ {
2802
+ name: "confluence_update_page",
2803
+ description:
2804
+ "Update an existing Confluence page. Title and/or body (storage XHTML). Version auto-bumps if omitted (fetches current version first). Confluence requires version.number = current + 1.",
2805
+ inputSchema: {
2806
+ type: "object",
2807
+ properties: {
2808
+ pageId: { type: "string", description: "Page ID (required)." },
2809
+ title: { type: "string", description: "New page title (optional — retains current if omitted)." },
2810
+ body: { type: "string", description: "New body as storage XHTML (optional — retains current if omitted)." },
2811
+ 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." },
2813
+ },
2814
+ required: ["pageId"],
2815
+ },
2816
+ },
2817
+ {
2818
+ name: "confluence_create_page",
2819
+ description:
2820
+ "Create a new Confluence page. Body must be storage XHTML. Optional parentId nests under an existing page.",
2821
+ inputSchema: {
2822
+ type: "object",
2823
+ properties: {
2824
+ spaceKey: { type: "string", description: "Target space key (required)." },
2825
+ title: { type: "string", description: "Page title (required)." },
2826
+ body: { type: "string", description: "Storage-format XHTML body (required)." },
2827
+ parentId: { type: "string", description: "Optional parent page ID to nest under." },
2828
+ instance: { type: "string", description: "Instance name. Uses default instance if omitted." },
2829
+ },
2830
+ required: ["spaceKey", "title", "body"],
2831
+ },
2832
+ },
2833
+ {
2834
+ name: "confluence_delete_page",
2835
+ description: "Delete a Confluence page by ID.",
2836
+ inputSchema: {
2837
+ type: "object",
2838
+ properties: {
2839
+ pageId: { type: "string", description: "Page ID (required)." },
2840
+ instance: { type: "string", description: "Instance name. Uses default instance if omitted." },
2841
+ },
2842
+ required: ["pageId"],
2843
+ },
2844
+ },
2845
+ {
2846
+ name: "confluence_get_labels",
2847
+ description: "List labels on a Confluence page.",
2848
+ inputSchema: {
2849
+ type: "object",
2850
+ properties: {
2851
+ pageId: { type: "string", description: "Page ID (required)." },
2852
+ instance: { type: "string", description: "Instance name. Uses default instance if omitted." },
2853
+ },
2854
+ required: ["pageId"],
2855
+ },
2856
+ },
2857
+ {
2858
+ name: "confluence_add_label",
2859
+ description: "Add a label to a Confluence page.",
2860
+ inputSchema: {
2861
+ type: "object",
2862
+ properties: {
2863
+ pageId: { type: "string", description: "Page ID (required)." },
2864
+ label: { type: "string", description: "Label name (required)." },
2865
+ instance: { type: "string", description: "Instance name. Uses default instance if omitted." },
2866
+ },
2867
+ required: ["pageId", "label"],
2868
+ },
2869
+ },
2870
+ {
2871
+ name: "confluence_remove_label",
2872
+ description: "Remove a label from a Confluence page.",
2873
+ inputSchema: {
2874
+ type: "object",
2875
+ properties: {
2876
+ pageId: { type: "string", description: "Page ID (required)." },
2877
+ label: { type: "string", description: "Label name (required)." },
2878
+ instance: { type: "string", description: "Instance name. Uses default instance if omitted." },
2879
+ },
2880
+ required: ["pageId", "label"],
2881
+ },
2882
+ },
2883
+ {
2884
+ name: "confluence_list_attachments",
2885
+ description: "List attachments on a Confluence page.",
2886
+ inputSchema: {
2887
+ type: "object",
2888
+ properties: {
2889
+ pageId: { type: "string", description: "Page ID (required)." },
2890
+ limit: { type: "number", description: "Max results (default: 50)." },
2891
+ instance: { type: "string", description: "Instance name. Uses default instance if omitted." },
2892
+ },
2893
+ required: ["pageId"],
2894
+ },
2895
+ },
2896
+ {
2897
+ name: "confluence_upload_attachment",
2898
+ description:
2899
+ "Upload a file as an attachment to a Confluence page. Provide either filePath or fileContent (base64) + fileName.",
2900
+ inputSchema: {
2901
+ type: "object",
2902
+ properties: {
2903
+ pageId: { type: "string", description: "Page ID (required)." },
2904
+ filePath: { type: "string", description: "Absolute path to the local file to upload." },
2905
+ fileContent: { type: "string", description: "Base64-encoded file content (requires fileName)." },
2906
+ fileName: { type: "string", description: "File name (required with fileContent; optional with filePath)." },
2907
+ comment: { type: "string", description: "Optional attachment comment." },
2908
+ instance: { type: "string", description: "Instance name. Uses default instance if omitted." },
2909
+ },
2910
+ required: ["pageId"],
2911
+ },
2912
+ },
2913
+ {
2914
+ name: "confluence_download_attachment",
2915
+ description:
2916
+ "Download an attachment from a Confluence page by filename. Saves under ~/.config/jira-mcp/confluence-attachments/<pageId>/ and returns the local path.",
2917
+ inputSchema: {
2918
+ type: "object",
2919
+ properties: {
2920
+ pageId: { type: "string", description: "Page ID (required)." },
2921
+ filename: { type: "string", description: "Attachment filename (required)." },
2922
+ instance: { type: "string", description: "Instance name. Uses default instance if omitted." },
2923
+ },
2924
+ required: ["pageId", "filename"],
2925
+ },
2926
+ },
2514
2927
  ],
2515
2928
  };
2516
2929
  });
@@ -3875,6 +4288,483 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3875
4288
  }
3876
4289
  return { content: [{ type: "text", text }] };
3877
4290
 
4291
+ } else if (name === "confluence_get_spaces") {
4292
+ const inst = getInstanceByName(args.instance);
4293
+ const result = await fetchConfluence(
4294
+ "/rest/api/space?type=global&limit=100",
4295
+ {},
4296
+ inst,
4297
+ );
4298
+ const spaces = (result.results || []).map((s) => ({
4299
+ key: s.key,
4300
+ name: s.name,
4301
+ id: s.id,
4302
+ }));
4303
+ if (spaces.length === 0) {
4304
+ return { content: [{ type: "text", text: "No Confluence spaces found." }] };
4305
+ }
4306
+ let text = `# Confluence Spaces (${spaces.length})\n\n`;
4307
+ for (const s of spaces) {
4308
+ text += `- **${s.name}** (key: ${s.key}, id: ${s.id})\n`;
4309
+ }
4310
+ return { content: [{ type: "text", text }] };
4311
+
4312
+ } else if (name === "confluence_get_space") {
4313
+ const inst = getInstanceByName(args.instance);
4314
+ const result = await fetchConfluence(
4315
+ `/rest/api/space/${encodeURIComponent(args.spaceKey)}?expand=description.plain,homepage`,
4316
+ {},
4317
+ inst,
4318
+ );
4319
+ const desc = result.description?.plain?.value || "";
4320
+ let text = `# Space ${result.name} (${result.key})\n\n`;
4321
+ text += `- **ID:** ${result.id}\n`;
4322
+ text += `- **Type:** ${result.type}\n`;
4323
+ if (result.homepage?.id) text += `- **Homepage:** ${result.homepage.title} (id: ${result.homepage.id})\n`;
4324
+ if (desc) text += `\n${desc}\n`;
4325
+ return { content: [{ type: "text", text }] };
4326
+
4327
+ } else if (name === "confluence_create_space") {
4328
+ const inst = getInstanceByName(args.instance);
4329
+ const body = {
4330
+ key: args.spaceKey.toUpperCase(),
4331
+ name: args.name,
4332
+ };
4333
+ if (args.description) {
4334
+ body.description = {
4335
+ plain: { value: args.description, representation: "plain" },
4336
+ };
4337
+ }
4338
+ const result = await fetchConfluence("/rest/api/space", { method: "POST", body }, inst);
4339
+ return {
4340
+ content: [
4341
+ {
4342
+ type: "text",
4343
+ text: `Created space **${result.name}** (key: ${result.key}, id: ${result.id}).`,
4344
+ },
4345
+ ],
4346
+ };
4347
+
4348
+ } else if (name === "confluence_update_space") {
4349
+ const inst = getInstanceByName(args.instance);
4350
+ if (!args.name && args.description === undefined) {
4351
+ return {
4352
+ content: [{ type: "text", text: "Provide at least one of name or description." }],
4353
+ isError: true,
4354
+ };
4355
+ }
4356
+ const body = {};
4357
+ if (args.name) body.name = args.name;
4358
+ if (args.description !== undefined) {
4359
+ body.description = {
4360
+ plain: { value: args.description, representation: "plain" },
4361
+ };
4362
+ }
4363
+ const result = await fetchConfluence(
4364
+ `/rest/api/space/${encodeURIComponent(args.spaceKey)}`,
4365
+ { method: "PUT", body },
4366
+ inst,
4367
+ );
4368
+ return {
4369
+ content: [
4370
+ {
4371
+ type: "text",
4372
+ text: `Updated space **${result.name || args.spaceKey}** (key: ${result.key || args.spaceKey}).`,
4373
+ },
4374
+ ],
4375
+ };
4376
+
4377
+ } else if (name === "confluence_delete_space") {
4378
+ const inst = getInstanceByName(args.instance);
4379
+ const result = await fetchConfluence(
4380
+ `/rest/api/space/${encodeURIComponent(args.spaceKey)}`,
4381
+ { method: "DELETE" },
4382
+ inst,
4383
+ );
4384
+ return {
4385
+ content: [
4386
+ {
4387
+ type: "text",
4388
+ text: `Delete requested for space ${args.spaceKey}.${result.id ? ` Long-running task id: ${result.id}.` : ""}`,
4389
+ },
4390
+ ],
4391
+ };
4392
+
4393
+ } else if (name === "confluence_search") {
4394
+ const inst = getInstanceByName(args.instance);
4395
+ const type = args.type || "page";
4396
+ const limit = args.limit || 25;
4397
+ const safeQuery = String(args.query).replace(/"/g, '\\"');
4398
+ let cql = `text ~ "${safeQuery}" AND type = ${type}`;
4399
+ if (args.spaceKey) cql += ` AND space = "${args.spaceKey}"`;
4400
+ cql += ` ORDER BY lastModified DESC`;
4401
+ const result = await fetchConfluence(
4402
+ `/rest/api/content/search?cql=${encodeURIComponent(cql)}&limit=${limit}&expand=content.version,content.space,content.history.lastUpdated`,
4403
+ {},
4404
+ inst,
4405
+ );
4406
+ const rows = result.results || [];
4407
+ if (rows.length === 0) {
4408
+ return { content: [{ type: "text", text: `No Confluence results for "${args.query}".` }] };
4409
+ }
4410
+ let text = `# Confluence search (${rows.length})\n\n`;
4411
+ for (const r of rows) {
4412
+ const spaceKey = r.space?.key || r.resultGlobalContainer?.displayUrl || "?";
4413
+ const updated = r.history?.lastUpdated?.when || r.version?.when || "";
4414
+ text += `- **${r.title}** (id: ${r.id}, type: ${r.type}, space: ${spaceKey})${updated ? ` — updated ${updated}` : ""}\n`;
4415
+ }
4416
+ return { content: [{ type: "text", text }] };
4417
+
4418
+ } else if (name === "confluence_get_recent_pages") {
4419
+ const inst = getInstanceByName(args.instance);
4420
+ const sinceDays = args.sinceDays || 30;
4421
+ const limit = args.limit || 25;
4422
+ let cql = `type = page`;
4423
+ if (args.spaceKey) cql += ` AND space = "${args.spaceKey}"`;
4424
+ cql += ` AND lastModified >= now("-${sinceDays}d") ORDER BY lastModified DESC`;
4425
+ const result = await fetchConfluence(
4426
+ `/rest/api/content/search?cql=${encodeURIComponent(cql)}&limit=${limit}&expand=content.version,content.space,content.history.lastUpdated`,
4427
+ {},
4428
+ inst,
4429
+ );
4430
+ const rows = result.results || [];
4431
+ if (rows.length === 0) {
4432
+ return { content: [{ type: "text", text: `No pages modified in the last ${sinceDays} days.` }] };
4433
+ }
4434
+ let text = `# Recently modified pages (${rows.length}, last ${sinceDays}d)\n\n`;
4435
+ for (const r of rows) {
4436
+ const spaceKey = r.space?.key || "?";
4437
+ const updated = r.history?.lastUpdated?.when || "";
4438
+ const by = r.history?.lastUpdated?.by?.displayName || "";
4439
+ text += `- **${r.title}** (id: ${r.id}, space: ${spaceKey})${updated ? ` — ${updated}` : ""}${by ? ` by ${by}` : ""}\n`;
4440
+ }
4441
+ return { content: [{ type: "text", text }] };
4442
+
4443
+ } else if (name === "confluence_get_space_root_pages") {
4444
+ const inst = getInstanceByName(args.instance);
4445
+ const limit = args.limit || 50;
4446
+ const result = await fetchConfluence(
4447
+ `/rest/api/content?type=page&spaceKey=${encodeURIComponent(args.spaceKey)}&depth=root&limit=${limit}&expand=version,history.lastUpdated,childTypes.page`,
4448
+ {},
4449
+ inst,
4450
+ );
4451
+ const rows = result.results || [];
4452
+ if (rows.length === 0) {
4453
+ return { content: [{ type: "text", text: `No root pages in space ${args.spaceKey}.` }] };
4454
+ }
4455
+ let text = `# Root pages in ${args.spaceKey} (${rows.length})\n\n`;
4456
+ for (const r of rows) {
4457
+ const hasChildren = r.childTypes?.page?.value ? " [has children]" : "";
4458
+ const updated = r.history?.lastUpdated?.when || "";
4459
+ text += `- **${r.title}** (id: ${r.id}, v${r.version?.number || "?"})${hasChildren}${updated ? ` — ${updated}` : ""}\n`;
4460
+ }
4461
+ return { content: [{ type: "text", text }] };
4462
+
4463
+ } else if (name === "confluence_get_page_children") {
4464
+ const inst = getInstanceByName(args.instance);
4465
+ const limit = args.limit || 50;
4466
+ const result = await fetchConfluence(
4467
+ `/rest/api/content/${encodeURIComponent(args.pageId)}/child/page?limit=${limit}&expand=version,history.lastUpdated,childTypes.page`,
4468
+ {},
4469
+ inst,
4470
+ );
4471
+ const rows = result.results || [];
4472
+ if (rows.length === 0) {
4473
+ return { content: [{ type: "text", text: `No child pages for ${args.pageId}.` }] };
4474
+ }
4475
+ let text = `# Children of page ${args.pageId} (${rows.length})\n\n`;
4476
+ for (const r of rows) {
4477
+ const hasChildren = r.childTypes?.page?.value ? " [has children]" : "";
4478
+ const updated = r.history?.lastUpdated?.when || "";
4479
+ text += `- **${r.title}** (id: ${r.id}, v${r.version?.number || "?"})${hasChildren}${updated ? ` — ${updated}` : ""}\n`;
4480
+ }
4481
+ return { content: [{ type: "text", text }] };
4482
+
4483
+ } else if (name === "confluence_get_page") {
4484
+ const inst = getInstanceByName(args.instance);
4485
+ const includeAdf = args.includeAdf !== false;
4486
+ const v1Path = `/rest/api/content/${encodeURIComponent(args.pageId)}?expand=body.view,body.storage,version,space,ancestors,history.lastUpdated,metadata.labels`;
4487
+ const v2Path = `/api/v2/pages/${encodeURIComponent(args.pageId)}?body-format=atlas_doc_format`;
4488
+ const [v1, v2] = await Promise.all([
4489
+ fetchConfluence(v1Path, {}, inst),
4490
+ includeAdf
4491
+ ? fetchConfluence(v2Path, {}, inst).catch((e) => ({ __adfError: e.message }))
4492
+ : Promise.resolve(null),
4493
+ ]);
4494
+ if (v2 && v2.body && v2.body.atlas_doc_format) {
4495
+ v1.body = v1.body || {};
4496
+ v1.body.atlas_doc_format = v2.body.atlas_doc_format;
4497
+ }
4498
+ const space = v1.space?.key || "?";
4499
+ const version = v1.version?.number || "?";
4500
+ const updatedBy = v1.history?.lastUpdated?.by?.displayName || "";
4501
+ const updatedAt = v1.history?.lastUpdated?.when || "";
4502
+ const labels = (v1.metadata?.labels?.results || []).map((l) => l.name).join(", ");
4503
+ const viewHtml = v1.body?.view?.value || "";
4504
+ let text = `# ${v1.title} (id: ${v1.id}, space: ${space}, v${version})\n\n`;
4505
+ if (updatedAt) text += `_Last updated ${updatedAt}${updatedBy ? ` by ${updatedBy}` : ""}_\n\n`;
4506
+ if (labels) text += `**Labels:** ${labels}\n\n`;
4507
+ if (viewHtml) {
4508
+ const stripped = viewHtml
4509
+ .replace(/<[^>]+>/g, " ")
4510
+ .replace(/\s+/g, " ")
4511
+ .trim();
4512
+ text += `${stripped.substring(0, 4000)}${stripped.length > 4000 ? "…" : ""}\n\n`;
4513
+ }
4514
+ if (includeAdf && v2 && v2.__adfError) {
4515
+ text += `\n_ADF fetch failed: ${v2.__adfError}_\n`;
4516
+ } else if (includeAdf && v1.body?.atlas_doc_format) {
4517
+ text += `_ADF body included (body.atlas_doc_format)._\n`;
4518
+ }
4519
+ text += `\n---\nFull page JSON:\n\`\`\`json\n${JSON.stringify(v1, null, 2).substring(0, 30000)}\n\`\`\``;
4520
+ return { content: [{ type: "text", text }] };
4521
+
4522
+ } else if (name === "confluence_get_comments") {
4523
+ const inst = getInstanceByName(args.instance);
4524
+ const limit = args.limit || 50;
4525
+ const result = await fetchConfluence(
4526
+ `/rest/api/content/${encodeURIComponent(args.pageId)}/child/comment?limit=${limit}&depth=all&expand=body.view,version,history.createdBy,history.lastUpdated`,
4527
+ {},
4528
+ inst,
4529
+ );
4530
+ const rows = result.results || [];
4531
+ if (rows.length === 0) {
4532
+ return { content: [{ type: "text", text: `No comments on page ${args.pageId}.` }] };
4533
+ }
4534
+ let text = `# Comments on page ${args.pageId} (${rows.length})\n\n`;
4535
+ for (const c of rows) {
4536
+ const author = c.history?.createdBy?.displayName || "Unknown";
4537
+ const when = c.history?.createdDate || c.version?.when || "";
4538
+ const bodyHtml = c.body?.view?.value || "";
4539
+ const stripped = bodyHtml.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
4540
+ text += `- **${author}** (id: ${c.id})${when ? ` — ${when}` : ""}\n ${stripped.substring(0, 500)}${stripped.length > 500 ? "…" : ""}\n`;
4541
+ }
4542
+ return { content: [{ type: "text", text }] };
4543
+
4544
+ } else if (name === "confluence_add_comment") {
4545
+ const inst = getInstanceByName(args.instance);
4546
+ const format = args.format || "text";
4547
+ const value = format === "storage" ? args.body : textToConfluenceStorage(args.body);
4548
+ const body = {
4549
+ type: "comment",
4550
+ container: { id: args.pageId, type: "page" },
4551
+ body: { storage: { value, representation: "storage" } },
4552
+ };
4553
+ const result = await fetchConfluence("/rest/api/content", { method: "POST", body }, inst);
4554
+ return {
4555
+ content: [
4556
+ {
4557
+ type: "text",
4558
+ text: `Comment added to page ${args.pageId} (comment id: ${result.id}).`,
4559
+ },
4560
+ ],
4561
+ };
4562
+
4563
+ } else if (name === "confluence_update_page") {
4564
+ const inst = getInstanceByName(args.instance);
4565
+ if (!args.title && args.body === undefined && !args.version) {
4566
+ return {
4567
+ content: [{ type: "text", text: "Provide at least one of title or body to update." }],
4568
+ isError: true,
4569
+ };
4570
+ }
4571
+ // Fetch current state so we can fill in anything the caller omitted.
4572
+ const current = await fetchConfluence(
4573
+ `/rest/api/content/${encodeURIComponent(args.pageId)}?expand=version,space,body.storage`,
4574
+ {},
4575
+ inst,
4576
+ );
4577
+ const versionNumber = args.version || (current.version?.number || 0) + 1;
4578
+ const title = args.title || current.title;
4579
+ const value = args.body !== undefined ? args.body : current.body?.storage?.value || "";
4580
+ const spaceKey = current.space?.key;
4581
+ const body = {
4582
+ id: args.pageId,
4583
+ type: "page",
4584
+ title,
4585
+ space: spaceKey ? { key: spaceKey } : undefined,
4586
+ body: { storage: { value, representation: "storage" } },
4587
+ version: { number: versionNumber },
4588
+ };
4589
+ const result = await fetchConfluence(
4590
+ `/rest/api/content/${encodeURIComponent(args.pageId)}`,
4591
+ { method: "PUT", body },
4592
+ inst,
4593
+ );
4594
+ return {
4595
+ content: [
4596
+ {
4597
+ type: "text",
4598
+ text: `Updated page **${result.title}** (id: ${result.id}, v${result.version?.number}).`,
4599
+ },
4600
+ ],
4601
+ };
4602
+
4603
+ } else if (name === "confluence_create_page") {
4604
+ const inst = getInstanceByName(args.instance);
4605
+ const body = {
4606
+ type: "page",
4607
+ title: args.title,
4608
+ space: { key: args.spaceKey },
4609
+ body: { storage: { value: args.body, representation: "storage" } },
4610
+ };
4611
+ if (args.parentId) body.ancestors = [{ id: args.parentId }];
4612
+ const result = await fetchConfluence("/rest/api/content", { method: "POST", body }, inst);
4613
+ const webui = result._links?.webui
4614
+ ? `${getConfluenceBaseUrl(inst)}${result._links.webui}`
4615
+ : "";
4616
+ return {
4617
+ content: [
4618
+ {
4619
+ type: "text",
4620
+ text: `Created page **${result.title}** (id: ${result.id}, space: ${args.spaceKey}).${webui ? `\nURL: ${webui}` : ""}`,
4621
+ },
4622
+ ],
4623
+ };
4624
+
4625
+ } else if (name === "confluence_delete_page") {
4626
+ const inst = getInstanceByName(args.instance);
4627
+ await fetchConfluence(
4628
+ `/rest/api/content/${encodeURIComponent(args.pageId)}`,
4629
+ { method: "DELETE" },
4630
+ inst,
4631
+ );
4632
+ return { content: [{ type: "text", text: `Deleted page ${args.pageId}.` }] };
4633
+
4634
+ } else if (name === "confluence_get_labels") {
4635
+ const inst = getInstanceByName(args.instance);
4636
+ const result = await fetchConfluence(
4637
+ `/rest/api/content/${encodeURIComponent(args.pageId)}/label`,
4638
+ {},
4639
+ inst,
4640
+ );
4641
+ const labels = (result.results || []).map((l) => l.name);
4642
+ if (labels.length === 0) {
4643
+ return { content: [{ type: "text", text: `No labels on page ${args.pageId}.` }] };
4644
+ }
4645
+ return { content: [{ type: "text", text: `Labels on ${args.pageId}: ${labels.join(", ")}` }] };
4646
+
4647
+ } else if (name === "confluence_add_label") {
4648
+ const inst = getInstanceByName(args.instance);
4649
+ await fetchConfluence(
4650
+ `/rest/api/content/${encodeURIComponent(args.pageId)}/label`,
4651
+ { method: "POST", body: [{ prefix: "global", name: args.label }] },
4652
+ inst,
4653
+ );
4654
+ return {
4655
+ content: [{ type: "text", text: `Added label "${args.label}" to page ${args.pageId}.` }],
4656
+ };
4657
+
4658
+ } else if (name === "confluence_remove_label") {
4659
+ const inst = getInstanceByName(args.instance);
4660
+ await fetchConfluence(
4661
+ `/rest/api/content/${encodeURIComponent(args.pageId)}/label?name=${encodeURIComponent(args.label)}`,
4662
+ { method: "DELETE" },
4663
+ inst,
4664
+ );
4665
+ return {
4666
+ content: [{ type: "text", text: `Removed label "${args.label}" from page ${args.pageId}.` }],
4667
+ };
4668
+
4669
+ } else if (name === "confluence_list_attachments") {
4670
+ const inst = getInstanceByName(args.instance);
4671
+ const limit = args.limit || 50;
4672
+ const result = await fetchConfluence(
4673
+ `/rest/api/content/${encodeURIComponent(args.pageId)}/child/attachment?limit=${limit}&expand=version,metadata`,
4674
+ {},
4675
+ inst,
4676
+ );
4677
+ const rows = result.results || [];
4678
+ if (rows.length === 0) {
4679
+ return { content: [{ type: "text", text: `No attachments on page ${args.pageId}.` }] };
4680
+ }
4681
+ let text = `# Attachments on page ${args.pageId} (${rows.length})\n\n`;
4682
+ for (const a of rows) {
4683
+ const size = a.extensions?.fileSize ? ` ${a.extensions.fileSize}B` : "";
4684
+ const mt = a.extensions?.mediaType || "";
4685
+ text += `- **${a.title}** (id: ${a.id}, v${a.version?.number || "?"})${mt ? ` ${mt}` : ""}${size}\n`;
4686
+ }
4687
+ return { content: [{ type: "text", text }] };
4688
+
4689
+ } else if (name === "confluence_upload_attachment") {
4690
+ const inst = getInstanceByName(args.instance);
4691
+ let fileBuffer;
4692
+ let fileName;
4693
+ if (args.filePath) {
4694
+ if (!fs.existsSync(args.filePath)) {
4695
+ return {
4696
+ content: [{ type: "text", text: `Error: File not found: ${args.filePath}` }],
4697
+ isError: true,
4698
+ };
4699
+ }
4700
+ fileBuffer = fs.readFileSync(args.filePath);
4701
+ fileName = args.fileName || path.basename(args.filePath);
4702
+ } else if (args.fileContent) {
4703
+ if (!args.fileName) {
4704
+ return {
4705
+ content: [{ type: "text", text: "Error: fileName is required when using fileContent." }],
4706
+ isError: true,
4707
+ };
4708
+ }
4709
+ fileBuffer = Buffer.from(args.fileContent, "base64");
4710
+ fileName = args.fileName;
4711
+ } else {
4712
+ return {
4713
+ content: [{ type: "text", text: "Error: Provide either filePath or fileContent." }],
4714
+ isError: true,
4715
+ };
4716
+ }
4717
+
4718
+ const boundary = "----ConfluenceMCPBoundary" + Date.now();
4719
+ const parts = [];
4720
+ parts.push(Buffer.from(
4721
+ `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${fileName}"\r\nContent-Type: application/octet-stream\r\n\r\n`,
4722
+ ));
4723
+ parts.push(fileBuffer);
4724
+ parts.push(Buffer.from(`\r\n`));
4725
+ if (args.comment) {
4726
+ parts.push(Buffer.from(
4727
+ `--${boundary}\r\nContent-Disposition: form-data; name="comment"\r\n\r\n${args.comment}\r\n`,
4728
+ ));
4729
+ }
4730
+ parts.push(Buffer.from(`--${boundary}--\r\n`));
4731
+ const bodyBuffer = Buffer.concat(parts);
4732
+
4733
+ const url = `${getConfluenceBaseUrl(inst)}/rest/api/content/${encodeURIComponent(args.pageId)}/child/attachment`;
4734
+ const response = await fetch(url, {
4735
+ method: "POST",
4736
+ headers: {
4737
+ Authorization: `Basic ${inst.auth}`,
4738
+ "X-Atlassian-Token": "no-check",
4739
+ "Content-Type": `multipart/form-data; boundary=${boundary}`,
4740
+ },
4741
+ body: bodyBuffer,
4742
+ });
4743
+ if (!response.ok) {
4744
+ const errorBody = await response.text().catch(() => "");
4745
+ throw new Error(
4746
+ `Confluence API error: ${response.status} ${response.statusText}${errorBody ? ` - ${errorBody}` : ""}`,
4747
+ );
4748
+ }
4749
+ const result = await response.json();
4750
+ const rows = result.results || (Array.isArray(result) ? result : [result]);
4751
+ const names = rows.map((a) => a.title || a.filename).join(", ");
4752
+ return {
4753
+ content: [{ type: "text", text: `Uploaded ${names} to page ${args.pageId}.` }],
4754
+ };
4755
+
4756
+ } else if (name === "confluence_download_attachment") {
4757
+ const inst = getInstanceByName(args.instance);
4758
+ const localPath = await downloadConfluenceAttachment(args.pageId, args.filename, inst);
4759
+ return {
4760
+ content: [
4761
+ {
4762
+ type: "text",
4763
+ text: `Downloaded "${args.filename}" from page ${args.pageId} to ${localPath}`,
4764
+ },
4765
+ ],
4766
+ };
4767
+
3878
4768
  } else {
3879
4769
  throw new Error(`Unknown tool: ${name}`);
3880
4770
  }
@@ -3897,5 +4787,5 @@ if (require.main === module) {
3897
4787
 
3898
4788
  // Export for testing
3899
4789
  if (typeof module !== "undefined") {
3900
- module.exports = { buildCommentADF, parseInlineFormatting, autoLinkTextNodes, findJiraTicketKeys, resolveTeamId, fetchJiraTeams, listTeams, searchTeamsViaJql };
4790
+ module.exports = { buildCommentADF, parseInlineFormatting, autoLinkTextNodes, findJiraTicketKeys, resolveTeamId, fetchJiraTeams, listTeams, searchTeamsViaJql, fetchConfluence, getConfluenceBaseUrl, textToConfluenceStorage, htmlEscape };
3901
4791
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rui.branco/jira-mcp",
3
- "version": "1.7.3",
3
+ "version": "1.7.4",
4
4
  "description": "Jira MCP server for Claude Code - fetch tickets, search with JQL, update tickets, manage comments, change status, and get Figma designs",
5
5
  "main": "index.js",
6
6
  "bin": {