@atlassian-dc-mcp/confluence 0.12.2 → 0.13.0

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 (36) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +41 -2
  3. package/build/__tests__/config.test.d.ts +2 -0
  4. package/build/__tests__/config.test.d.ts.map +1 -0
  5. package/build/__tests__/config.test.js +49 -0
  6. package/build/__tests__/config.test.js.map +1 -0
  7. package/build/__tests__/confluence-response-mapper.test.d.ts +2 -0
  8. package/build/__tests__/confluence-response-mapper.test.d.ts.map +1 -0
  9. package/build/__tests__/confluence-response-mapper.test.js +24 -0
  10. package/build/__tests__/confluence-response-mapper.test.js.map +1 -0
  11. package/build/__tests__/confluence-service.test.js +125 -5
  12. package/build/__tests__/confluence-service.test.js.map +1 -1
  13. package/build/config.d.ts +4 -0
  14. package/build/config.d.ts.map +1 -0
  15. package/build/config.js +11 -0
  16. package/build/config.js.map +1 -0
  17. package/build/confluence-response-mapper.d.ts +13 -0
  18. package/build/confluence-response-mapper.d.ts.map +1 -0
  19. package/build/confluence-response-mapper.js +87 -0
  20. package/build/confluence-response-mapper.js.map +1 -0
  21. package/build/confluence-service.d.ts +18 -4
  22. package/build/confluence-service.d.ts.map +1 -1
  23. package/build/confluence-service.js +42 -22
  24. package/build/confluence-service.js.map +1 -1
  25. package/build/index.js +27 -14
  26. package/build/index.js.map +1 -1
  27. package/package.json +3 -3
  28. package/server.json +12 -5
  29. package/src/__tests__/config.test.ts +64 -0
  30. package/src/__tests__/confluence-response-mapper.test.ts +26 -0
  31. package/src/__tests__/confluence-service.test.ts +161 -5
  32. package/src/config.ts +13 -0
  33. package/src/confluence-response-mapper.ts +104 -0
  34. package/src/confluence-service.ts +69 -26
  35. package/src/index.ts +31 -16
  36. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,104 @@
1
+ export type ConfluenceBodyMode = 'storage' | 'text' | 'none';
2
+ export type ConfluenceMutationOutputMode = 'ack' | 'full';
3
+
4
+ const ENTITY_MAP: Record<string, string> = {
5
+ '&nbsp;': ' ',
6
+ '&amp;': '&',
7
+ '&lt;': '<',
8
+ '&gt;': '>',
9
+ '&quot;': '"',
10
+ '&#39;': "'",
11
+ };
12
+
13
+ function decodeHtmlEntities(value: string): string {
14
+ return value
15
+ .replace(/&(nbsp|amp|lt|gt|quot);|&#39;/g, match => ENTITY_MAP[match] ?? match)
16
+ .replace(/&#(\d+);/g, (_, code) => {
17
+ const parsed = Number.parseInt(code, 10);
18
+ return Number.isNaN(parsed) ? '' : String.fromCodePoint(parsed);
19
+ });
20
+ }
21
+
22
+ function truncateText(value: string, maxBodyChars?: number): { value: string; truncated?: boolean; originalLength?: number } {
23
+ if (maxBodyChars === undefined || maxBodyChars < 1 || value.length <= maxBodyChars) {
24
+ return { value };
25
+ }
26
+
27
+ return {
28
+ value: value.slice(0, maxBodyChars).trimEnd(),
29
+ truncated: true,
30
+ originalLength: value.length,
31
+ };
32
+ }
33
+
34
+ export function confluenceStorageToText(storageValue: string): string {
35
+ const withLineBreaks = storageValue
36
+ .replace(/<br\s*\/?>/gi, '\n')
37
+ .replace(/<li\b[^>]*>/gi, '\n- ')
38
+ .replace(/<\/(?:p|div|h[1-6]|li|tr|td|th|blockquote|pre|ul|ol|table|section|article)\s*>/gi, '\n')
39
+ .replace(/<[^>]+>/g, ' ');
40
+
41
+ return decodeHtmlEntities(withLineBreaks)
42
+ .replace(/\r\n/g, '\n')
43
+ .replace(/[ \t]+\n/g, '\n')
44
+ .replace(/\n{3,}/g, '\n\n')
45
+ .replace(/[ \t]{2,}/g, ' ')
46
+ .trim();
47
+ }
48
+
49
+ function getContentUrl(content: any): string | undefined {
50
+ const self = content?._links?.self;
51
+ if (typeof self === 'string') {
52
+ return self;
53
+ }
54
+
55
+ const base = content?._links?.base;
56
+ const webui = content?._links?.webui;
57
+ if (typeof base === 'string' && typeof webui === 'string') {
58
+ return `${base}${webui}`;
59
+ }
60
+
61
+ return undefined;
62
+ }
63
+
64
+ export function shapeConfluenceContent(content: any, bodyMode: ConfluenceBodyMode = 'storage', maxBodyChars?: number) {
65
+ if (!content || typeof content !== 'object' || bodyMode === 'storage') {
66
+ return content;
67
+ }
68
+
69
+ const { body, ...rest } = content as Record<string, any>;
70
+
71
+ if (bodyMode === 'none') {
72
+ return rest;
73
+ }
74
+
75
+ const storageValue = typeof body?.storage?.value === 'string' ? body.storage.value : undefined;
76
+ if (storageValue === undefined) {
77
+ return rest;
78
+ }
79
+
80
+ const textBody = truncateText(confluenceStorageToText(storageValue), maxBodyChars);
81
+ return {
82
+ ...rest,
83
+ body: {
84
+ text: {
85
+ value: textBody.value,
86
+ representation: 'text',
87
+ ...(textBody.truncated ? { truncated: true, originalLength: textBody.originalLength } : {}),
88
+ },
89
+ },
90
+ };
91
+ }
92
+
93
+ export function shapeConfluenceMutationAck(content: any) {
94
+ const url = getContentUrl(content);
95
+
96
+ return {
97
+ ...(content?.id !== undefined ? { id: content.id } : {}),
98
+ ...(typeof content?.type === 'string' ? { type: content.type } : {}),
99
+ ...(typeof content?.title === 'string' ? { title: content.title } : {}),
100
+ ...(typeof content?.space?.key === 'string' ? { spaceKey: content.space.key } : {}),
101
+ ...(content?.version?.number !== undefined ? { version: content.version.number } : {}),
102
+ ...(url ? { url } : {}),
103
+ };
104
+ }
@@ -1,6 +1,8 @@
1
1
  import { z } from 'zod';
2
2
  import { ContentResourceService, OpenAPI, SearchService } from './confluence-client/index.js';
3
3
  import { handleApiOperation } from '@atlassian-dc-mcp/common';
4
+ import { getDefaultPageSize, getMissingConfig } from './config.js';
5
+ import { ConfluenceBodyMode, shapeConfluenceContent } from './confluence-response-mapper.js';
4
6
 
5
7
  /**
6
8
  * Escapes user input for safe use inside a CQL quoted string.
@@ -31,14 +33,31 @@ export interface ConfluenceContent {
31
33
  ancestors?: Array<{ id: string }>;
32
34
  }
33
35
 
36
+ function resolveToken(token: string | (() => string | undefined), missingTokenMessage: string) {
37
+ return async () => {
38
+ const resolvedToken = typeof token === 'function' ? token() : token;
39
+ if (!resolvedToken) {
40
+ throw new Error(missingTokenMessage);
41
+ }
42
+ return resolvedToken;
43
+ };
44
+ }
45
+
34
46
  export class ConfluenceService {
47
+ private readonly getPageSize: () => number;
48
+
35
49
  /**
36
50
  * Creates a new ConfluenceService instance
37
51
  * @param host The hostname of the Confluence server (e.g., "host.com")
38
52
  * @param token The API token for authentication
39
53
  * @param fullApiUrl Optional full API URL (e.g., "https://host.com/wiki/"). If provided, host and apiBasePath are ignored.
40
54
  */
41
- constructor(host: string | undefined, token: string, fullApiUrl?: string) {
55
+ constructor(
56
+ host: string | undefined,
57
+ token: string | (() => string | undefined),
58
+ fullApiUrl?: string,
59
+ getPageSize: () => number = getDefaultPageSize,
60
+ ) {
42
61
  if (fullApiUrl) {
43
62
  OpenAPI.BASE = fullApiUrl;
44
63
  } else if (host) {
@@ -46,15 +65,16 @@ export class ConfluenceService {
46
65
  } else {
47
66
  throw new Error('Either host or fullApiUrl must be provided');
48
67
  }
49
- OpenAPI.TOKEN = token;
68
+ OpenAPI.TOKEN = resolveToken(token, 'Missing required environment variable: CONFLUENCE_API_TOKEN');
50
69
  OpenAPI.VERSION = '1.0';
70
+ this.getPageSize = getPageSize;
51
71
  }
52
72
  /**
53
73
  * Get a Confluence page by ID
54
74
  * @param contentId The ID of the page to retrieve
55
75
  * @param expand Optional comma-separated list of properties to expand
56
76
  */
57
- async getContent(contentId: string, expand?: string) {
77
+ async getContentRaw(contentId: string, expand?: string) {
58
78
  const expandValue = expand || 'body.storage';
59
79
  const finalExpand = expand && !expand.includes('body.storage')
60
80
  ? `${expand},body.storage`
@@ -62,6 +82,18 @@ export class ConfluenceService {
62
82
  return handleApiOperation(() => ContentResourceService.getContentById(contentId, finalExpand), 'Error getting content');
63
83
  }
64
84
 
85
+ async getContent(contentId: string, expand?: string, bodyMode: ConfluenceBodyMode = 'storage', maxBodyChars?: number) {
86
+ const result = await this.getContentRaw(contentId, expand);
87
+ if (result.success && result.data) {
88
+ return {
89
+ ...result,
90
+ data: shapeConfluenceContent(result.data, bodyMode, maxBodyChars),
91
+ };
92
+ }
93
+
94
+ return result;
95
+ }
96
+
65
97
  /**
66
98
  * Search for content in Confluence using CQL
67
99
  * @param cql Confluence Query Language string
@@ -69,8 +101,19 @@ export class ConfluenceService {
69
101
  * @param start Start index for pagination
70
102
  * @param expand Optional comma-separated list of properties to expand
71
103
  */
72
- async searchContent(cql: string, limit?: number, start?: number, expand?: string) {
73
- return handleApiOperation(() => SearchService.search1(undefined, expand, undefined, limit?.toString(), start?.toString(), undefined, cql), 'Error searching for content');
104
+ async searchContent(cql: string, limit?: number, start?: number, expand?: string, excerpt: 'none' | 'highlight' = 'none') {
105
+ return handleApiOperation(
106
+ () => SearchService.search1(
107
+ undefined,
108
+ expand,
109
+ undefined,
110
+ (limit ?? this.getPageSize()).toString(),
111
+ start?.toString(),
112
+ excerpt,
113
+ cql
114
+ ),
115
+ 'Error searching for content'
116
+ );
74
117
  }
75
118
 
76
119
  /**
@@ -97,7 +140,13 @@ export class ConfluenceService {
97
140
  * @param start Start index for pagination
98
141
  * @param expand Optional comma-separated list of properties to expand
99
142
  */
100
- async searchSpaces(searchText: string, limit?: number, start?: number, expand?: string) {
143
+ async searchSpaces(
144
+ searchText: string,
145
+ limit?: number,
146
+ start?: number,
147
+ expand?: string,
148
+ excerpt: 'none' | 'highlight' = 'none'
149
+ ) {
101
150
  // Create a CQL query that searches for spaces
102
151
  // The correct syntax for space search is: type=space AND title ~ "searchText"
103
152
  const escapedSearchText = escapeSearchTextForCql(searchText);
@@ -107,59 +156,53 @@ export class ConfluenceService {
107
156
  undefined,
108
157
  expand,
109
158
  undefined,
110
- limit?.toString(),
159
+ (limit ?? this.getPageSize()).toString(),
111
160
  start?.toString(),
112
- undefined,
161
+ excerpt,
113
162
  cql
114
163
  ), 'Error searching for spaces');
115
164
  }
116
165
 
117
166
  static validateConfig(): string[] {
118
- const missingVars: string[] = [];
119
-
120
- // API token is always required
121
- if (!process.env.CONFLUENCE_API_TOKEN) {
122
- missingVars.push('CONFLUENCE_API_TOKEN');
123
- }
124
-
125
- // Either CONFLUENCE_HOST or CONFLUENCE_API_BASE_PATH must be set
126
- if (!process.env.CONFLUENCE_HOST && !process.env.CONFLUENCE_API_BASE_PATH) {
127
- missingVars.push('CONFLUENCE_HOST or CONFLUENCE_API_BASE_PATH');
128
- }
129
-
130
- return missingVars;
167
+ return getMissingConfig();
131
168
  }
132
169
  }
133
170
 
134
171
  export const confluenceToolSchemas = {
135
172
  getContent: {
136
173
  contentId: z.string().describe("Confluence Data Center content ID"),
137
- expand: z.string().optional().describe("Comma-separated list of properties to expand")
174
+ expand: z.string().optional().describe("Comma-separated list of properties to expand"),
175
+ bodyMode: z.enum(['storage', 'text', 'none']).optional().describe("How to return the page body. Defaults to storage for backward compatibility."),
176
+ maxBodyChars: z.number().optional().describe("Maximum number of characters to keep when bodyMode is text")
138
177
  },
139
178
  searchContent: {
140
179
  cql: z.string().describe("Confluence Query Language (CQL) search string for Confluence Data Center"),
141
180
  limit: z.number().optional().describe("Maximum number of results to return"),
142
181
  start: z.number().optional().describe("Start index for pagination"),
143
- expand: z.string().optional().describe("Comma-separated list of properties to expand")
182
+ expand: z.string().optional().describe("Comma-separated list of properties to expand"),
183
+ excerpt: z.enum(['none', 'highlight']).optional().describe("Excerpt mode for search results. Defaults to none.")
144
184
  },
145
185
  createContent: {
146
186
  title: z.string().describe("Title of the content"),
147
187
  spaceKey: z.string().describe("Space key where content will be created"),
148
188
  type: z.string().default("page").describe("Content type (page, blogpost, etc)"),
149
189
  content: z.string().describe("Content body in Confluence Data Center \"storage\" format (confluence XML)"),
150
- parentId: z.string().optional().describe("ID of the parent page (if creating a child page)")
190
+ parentId: z.string().optional().describe("ID of the parent page (if creating a child page)"),
191
+ output: z.enum(['ack', 'full']).optional().describe("Return a compact acknowledgement or the full API response. Defaults to ack.")
151
192
  },
152
193
  updateContent: {
153
194
  contentId: z.string().describe("ID of the content to update"),
154
195
  title: z.string().optional().describe("New title of the content"),
155
196
  content: z.string().optional().describe("New content body in Confluence Data Center storage format (XML-based)"),
156
197
  version: z.number().describe("New version number (must be incremented)"),
157
- versionComment: z.string().optional().describe("Comment for this version")
198
+ versionComment: z.string().optional().describe("Comment for this version"),
199
+ output: z.enum(['ack', 'full']).optional().describe("Return a compact acknowledgement or the full API response. Defaults to ack.")
158
200
  },
159
201
  searchSpaces: {
160
202
  searchText: z.string().describe("Text to search for in Confluence Data Center space names or descriptions. Quotes and backslashes are escaped for CQL; pass the literal search phrase only (do not pre-escape)."),
161
203
  limit: z.number().optional().describe("Maximum number of results to return"),
162
204
  start: z.number().optional().describe("Start index for pagination"),
163
- expand: z.string().optional().describe("Comma-separated list of properties to expand")
205
+ expand: z.string().optional().describe("Comma-separated list of properties to expand"),
206
+ excerpt: z.enum(['none', 'highlight']).optional().describe("Excerpt mode for search results. Defaults to none.")
164
207
  }
165
208
  };
package/src/index.ts CHANGED
@@ -1,9 +1,9 @@
1
- import { connectServer, createMcpServer, formatToolResponse } from '@atlassian-dc-mcp/common';
1
+ import { connectServer, createMcpServer, formatToolResponse, initializeRuntimeConfig } from '@atlassian-dc-mcp/common';
2
2
  import { ConfluenceService, ConfluenceContent, confluenceToolSchemas } from './confluence-service.js';
3
- import dotenv from 'dotenv';
3
+ import { shapeConfluenceMutationAck } from './confluence-response-mapper.js';
4
+ import { getConfluenceRuntimeConfig, getDefaultPageSize } from './config.js';
4
5
 
5
- // Load environment variables
6
- dotenv.config();
6
+ initializeRuntimeConfig();
7
7
 
8
8
  // Validate required environment variables
9
9
  const missingEnvVars = ConfluenceService.validateConfig();
@@ -12,10 +12,12 @@ if (missingEnvVars.length > 0) {
12
12
  }
13
13
 
14
14
  // Initialize Confluence service
15
+ const confluenceConfig = getConfluenceRuntimeConfig();
15
16
  const confluenceService = new ConfluenceService(
16
- process.env.CONFLUENCE_API_BASE_PATH ? undefined : process.env.CONFLUENCE_HOST!,
17
- process.env.CONFLUENCE_API_TOKEN!,
18
- process.env.CONFLUENCE_API_BASE_PATH
17
+ confluenceConfig.host,
18
+ () => getConfluenceRuntimeConfig().token,
19
+ confluenceConfig.apiBasePath,
20
+ getDefaultPageSize
19
21
  );
20
22
 
21
23
  // Define Confluence instance type
@@ -32,8 +34,8 @@ server.tool(
32
34
  "confluence_getContent",
33
35
  `Get Confluence content by ID from the ${confluenceInstanceType}`,
34
36
  confluenceToolSchemas.getContent,
35
- async ({ contentId, expand }) => {
36
- const result = await confluenceService.getContent(contentId, expand);
37
+ async ({ contentId, expand, bodyMode, maxBodyChars }) => {
38
+ const result = await confluenceService.getContent(contentId, expand, bodyMode, maxBodyChars);
37
39
  return formatToolResponse(result);
38
40
  }
39
41
  );
@@ -42,8 +44,8 @@ server.tool(
42
44
  "confluence_searchContent",
43
45
  `Search for content in ${confluenceInstanceType} using CQL`,
44
46
  confluenceToolSchemas.searchContent,
45
- async ({ cql, limit, start, expand }) => {
46
- const result = await confluenceService.searchContent(cql, limit, start, expand);
47
+ async ({ cql, limit, start, expand, excerpt }) => {
48
+ const result = await confluenceService.searchContent(cql, limit, start, expand, excerpt);
47
49
  return formatToolResponse(result);
48
50
  }
49
51
  );
@@ -52,7 +54,7 @@ server.tool(
52
54
  "confluence_createContent",
53
55
  `Create new content in ${confluenceInstanceType}`,
54
56
  confluenceToolSchemas.createContent,
55
- async ({ title, spaceKey, type, content, parentId }) => {
57
+ async ({ title, spaceKey, type, content, parentId, output }) => {
56
58
  const contentObj: ConfluenceContent = {
57
59
  type: type || 'page',
58
60
  title,
@@ -71,6 +73,12 @@ server.tool(
71
73
  }
72
74
 
73
75
  const result = await confluenceService.createContent(contentObj);
76
+ if (result.success && result.data && output !== 'full') {
77
+ return formatToolResponse({
78
+ ...result,
79
+ data: shapeConfluenceMutationAck(result.data),
80
+ });
81
+ }
74
82
  return formatToolResponse(result);
75
83
  }
76
84
  );
@@ -79,9 +87,9 @@ server.tool(
79
87
  "confluence_updateContent",
80
88
  `Update existing content in ${confluenceInstanceType}`,
81
89
  confluenceToolSchemas.updateContent,
82
- async ({ contentId, title, content, version, versionComment }) => {
90
+ async ({ contentId, title, content, version, versionComment, output }) => {
83
91
  // First get the current content to build upon
84
- const currentContent = await confluenceService.getContent(contentId);
92
+ const currentContent = await confluenceService.getContentRaw(contentId);
85
93
 
86
94
  if (!currentContent.success || !currentContent.data) {
87
95
  return formatToolResponse({
@@ -119,6 +127,12 @@ server.tool(
119
127
  }
120
128
 
121
129
  const result = await confluenceService.updateContent(contentId, updateObj);
130
+ if (result.success && result.data && output !== 'full') {
131
+ return formatToolResponse({
132
+ ...result,
133
+ data: shapeConfluenceMutationAck(result.data),
134
+ });
135
+ }
122
136
  return formatToolResponse(result);
123
137
  }
124
138
  );
@@ -130,9 +144,10 @@ server.tool('confluence_searchSpace',
130
144
  searchText,
131
145
  limit,
132
146
  start,
133
- expand
147
+ expand,
148
+ excerpt
134
149
  }) => {
135
- const result = await confluenceService.searchSpaces(searchText, limit, start, expand);
150
+ const result = await confluenceService.searchSpaces(searchText, limit, start, expand, excerpt);
136
151
  return formatToolResponse(result);
137
152
  });
138
153