@atlassian-dc-mcp/confluence 0.11.1 → 0.12.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.
- package/CHANGELOG.md +20 -0
- package/README.md +14 -0
- package/build/__tests__/confluence-service.test.d.ts +2 -0
- package/build/__tests__/confluence-service.test.d.ts.map +1 -0
- package/build/__tests__/confluence-service.test.js +78 -0
- package/build/__tests__/confluence-service.test.js.map +1 -0
- package/build/confluence-service.d.ts +6 -0
- package/build/confluence-service.d.ts.map +1 -1
- package/build/confluence-service.js +11 -2
- package/build/confluence-service.js.map +1 -1
- package/jest.config.js +23 -0
- package/package.json +3 -3
- package/src/__tests__/confluence-service.test.ts +124 -0
- package/src/confluence-service.ts +12 -2
- package/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,26 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# [0.12.0](https://github.com/b1ff/atlassian-dc-mcp/compare/v0.11.2...v0.12.0) (2026-03-18)
|
|
7
|
+
|
|
8
|
+
**Note:** Version bump only for package @atlassian-dc-mcp/confluence
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
## [0.11.2](https://github.com/b1ff/atlassian-dc-mcp/compare/v0.11.1...v0.11.2) (2026-03-12)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Bug Fixes
|
|
18
|
+
|
|
19
|
+
* **confluence:** escape quotes in space search query ([5583bd1](https://github.com/b1ff/atlassian-dc-mcp/commit/5583bd1a5ae4b1e0b5328d14d7ed0a4a5a36154f))
|
|
20
|
+
* **confluence:** robustly escape CQL search text and add test suite ([8104211](https://github.com/b1ff/atlassian-dc-mcp/commit/8104211a695b8411aa9e5f4ba8a977ada58d2157))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
6
26
|
## [0.11.1](https://github.com/b1ff/atlassian-dc-mcp/compare/v0.11.0...v0.11.1) (2026-03-12)
|
|
7
27
|
|
|
8
28
|
**Note:** Version bump only for package @atlassian-dc-mcp/confluence
|
package/README.md
CHANGED
|
@@ -60,6 +60,20 @@ Or for development with auto-reload:
|
|
|
60
60
|
npm run dev
|
|
61
61
|
```
|
|
62
62
|
|
|
63
|
+
## Testing
|
|
64
|
+
|
|
65
|
+
Run the test suite from the package directory:
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
npm run test
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Or from the repository root:
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
npm run test --workspace=@atlassian-dc-mcp/confluence
|
|
75
|
+
```
|
|
76
|
+
|
|
63
77
|
### Available Tools
|
|
64
78
|
|
|
65
79
|
#### 1. confluence_getContent
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"confluence-service.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/confluence-service.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { ConfluenceService, escapeSearchTextForCql } from '../confluence-service.js';
|
|
2
|
+
import { SearchService } from '../confluence-client/index.js';
|
|
3
|
+
jest.mock('../confluence-client/index.js', () => ({
|
|
4
|
+
ContentResourceService: {},
|
|
5
|
+
SearchService: {
|
|
6
|
+
search1: jest.fn(),
|
|
7
|
+
},
|
|
8
|
+
OpenAPI: {
|
|
9
|
+
BASE: '',
|
|
10
|
+
TOKEN: '',
|
|
11
|
+
VERSION: '',
|
|
12
|
+
},
|
|
13
|
+
}));
|
|
14
|
+
describe('escapeSearchTextForCql', () => {
|
|
15
|
+
it('returns plain text unchanged', () => {
|
|
16
|
+
expect(escapeSearchTextForCql('hello')).toBe('hello');
|
|
17
|
+
expect(escapeSearchTextForCql('space name')).toBe('space name');
|
|
18
|
+
});
|
|
19
|
+
it('escapes double quotes', () => {
|
|
20
|
+
expect(escapeSearchTextForCql('say "hello"')).toBe('say \\"hello\\"');
|
|
21
|
+
});
|
|
22
|
+
it('escapes backslashes first so they cannot escape the following quote', () => {
|
|
23
|
+
expect(escapeSearchTextForCql('\\')).toBe('\\\\');
|
|
24
|
+
expect(escapeSearchTextForCql('path\\to\\space')).toBe('path\\\\to\\\\space');
|
|
25
|
+
});
|
|
26
|
+
it('escapes backslash then quote correctly (order matters)', () => {
|
|
27
|
+
expect(escapeSearchTextForCql('\\"')).toBe('\\\\\\"');
|
|
28
|
+
});
|
|
29
|
+
it('escapes quote then backslash correctly', () => {
|
|
30
|
+
expect(escapeSearchTextForCql('"\\')).toBe('\\"\\\\');
|
|
31
|
+
});
|
|
32
|
+
it('prevents CQL injection via quoted phrase breakout', () => {
|
|
33
|
+
const malicious = '" OR type=page AND text ~ "secret';
|
|
34
|
+
const escaped = escapeSearchTextForCql(malicious);
|
|
35
|
+
expect(escaped).toContain('\\"');
|
|
36
|
+
expect(escaped).not.toBe(malicious);
|
|
37
|
+
});
|
|
38
|
+
it('double-escaping is not idempotent (call once only)', () => {
|
|
39
|
+
const input = 'foo"bar\\baz';
|
|
40
|
+
const once = escapeSearchTextForCql(input);
|
|
41
|
+
const twice = escapeSearchTextForCql(once);
|
|
42
|
+
expect(twice).not.toBe(once);
|
|
43
|
+
expect(twice).toContain('\\\\');
|
|
44
|
+
});
|
|
45
|
+
it('handles empty string', () => {
|
|
46
|
+
expect(escapeSearchTextForCql('')).toBe('');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
describe('ConfluenceService.searchSpaces', () => {
|
|
50
|
+
let service;
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
service = new ConfluenceService('test-host', 'test-token');
|
|
53
|
+
jest.clearAllMocks();
|
|
54
|
+
});
|
|
55
|
+
it('builds CQL with escaped searchText and calls SearchService', async () => {
|
|
56
|
+
SearchService.search1.mockResolvedValue({ results: [] });
|
|
57
|
+
await service.searchSpaces('my space', 10, 0);
|
|
58
|
+
expect(SearchService.search1).toHaveBeenCalledWith(undefined, undefined, undefined, '10', '0', undefined, 'type=space AND title ~ "my space"');
|
|
59
|
+
});
|
|
60
|
+
it('escapes quotes in searchText in the CQL passed to the API', async () => {
|
|
61
|
+
SearchService.search1.mockResolvedValue({ results: [] });
|
|
62
|
+
await service.searchSpaces('say "hello"', 5);
|
|
63
|
+
expect(SearchService.search1).toHaveBeenCalledWith(undefined, undefined, undefined, '5', undefined, undefined, 'type=space AND title ~ "say \\"hello\\""');
|
|
64
|
+
});
|
|
65
|
+
it('escapes backslashes in searchText in the CQL passed to the API', async () => {
|
|
66
|
+
SearchService.search1.mockResolvedValue({ results: [] });
|
|
67
|
+
await service.searchSpaces('path\\to\\space', 5);
|
|
68
|
+
expect(SearchService.search1).toHaveBeenCalledWith(undefined, undefined, undefined, '5', undefined, undefined, 'type=space AND title ~ "path\\\\to\\\\space"');
|
|
69
|
+
});
|
|
70
|
+
it('forwards API errors via handleApiOperation', async () => {
|
|
71
|
+
const err = new Error('API error');
|
|
72
|
+
SearchService.search1.mockRejectedValue(err);
|
|
73
|
+
const result = await service.searchSpaces('test');
|
|
74
|
+
expect(result.success).toBe(false);
|
|
75
|
+
expect(result.error).toBeDefined();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
//# sourceMappingURL=confluence-service.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"confluence-service.test.js","sourceRoot":"","sources":["../../src/__tests__/confluence-service.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* Escapes user input for safe use inside a CQL quoted string.
|
|
4
|
+
* Escapes backslash first, then double quote, so that neither can break out of the phrase.
|
|
5
|
+
* Only call once per value; double-escaping would over-escape and break the query.
|
|
6
|
+
*/
|
|
7
|
+
export declare function escapeSearchTextForCql(searchText: string): string;
|
|
2
8
|
export interface ConfluenceContent {
|
|
3
9
|
id?: string;
|
|
4
10
|
type: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"confluence-service.d.ts","sourceRoot":"","sources":["../src/confluence-service.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB,MAAM,WAAW,iBAAiB;IAChC,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE;QACL,GAAG,EAAE,MAAM,CAAC;KACb,CAAC;IACF,IAAI,CAAC,EAAE;QACL,OAAO,EAAE;YACP,KAAK,EAAE,MAAM,CAAC;YACd,cAAc,EAAE,SAAS,CAAC;SAC3B,CAAC;KACH,CAAC;IACF,OAAO,CAAC,EAAE;QACR,MAAM,EAAE,MAAM,CAAC;QACf,OAAO,CAAC,EAAE,MAAM,CAAC;KAClB,CAAC;IACF,SAAS,CAAC,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACnC;AAED,qBAAa,iBAAiB;IAC5B;;;;;OAKG;gBACS,IAAI,EAAE,MAAM,GAAG,SAAS,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM;IAWxE;;;;OAIG;IACG,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM;IAQnD;;;;;;OAMG;IACG,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM;;;;;;;
|
|
1
|
+
{"version":3,"file":"confluence-service.d.ts","sourceRoot":"","sources":["../src/confluence-service.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAEjE;AAED,MAAM,WAAW,iBAAiB;IAChC,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE;QACL,GAAG,EAAE,MAAM,CAAC;KACb,CAAC;IACF,IAAI,CAAC,EAAE;QACL,OAAO,EAAE;YACP,KAAK,EAAE,MAAM,CAAC;YACd,cAAc,EAAE,SAAS,CAAC;SAC3B,CAAC;KACH,CAAC;IACF,OAAO,CAAC,EAAE;QACR,MAAM,EAAE,MAAM,CAAC;QACf,OAAO,CAAC,EAAE,MAAM,CAAC;KAClB,CAAC;IACF,SAAS,CAAC,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACnC;AAED,qBAAa,iBAAiB;IAC5B;;;;;OAKG;gBACS,IAAI,EAAE,MAAM,GAAG,SAAS,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM;IAWxE;;;;OAIG;IACG,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM;IAQnD;;;;;;OAMG;IACG,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM;;;;;;;gBAC7C,CAAC;mBAA6B,CAAC;gBAA0B,CAAC;gBAA0B,CAAC;gBAA0B,CAAC;;;IAGnJ;;;OAGG;IACG,aAAa,CAAC,OAAO,EAAE,iBAAiB;IAI9C;;;;OAIG;IACG,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,iBAAiB;IAIjE;;;;;;OAMG;IACG,YAAY,CAAC,UAAU,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM;;;;;;;gBA3BnD,CAAC;mBAA6B,CAAC;gBAA0B,CAAC;gBAA0B,CAAC;gBAA0B,CAAC;;;IA4CnJ,MAAM,CAAC,cAAc,IAAI,MAAM,EAAE;CAelC;AAED,eAAO,MAAM,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+BjC,CAAC"}
|
|
@@ -1,6 +1,14 @@
|
|
|
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
|
+
/**
|
|
5
|
+
* Escapes user input for safe use inside a CQL quoted string.
|
|
6
|
+
* Escapes backslash first, then double quote, so that neither can break out of the phrase.
|
|
7
|
+
* Only call once per value; double-escaping would over-escape and break the query.
|
|
8
|
+
*/
|
|
9
|
+
export function escapeSearchTextForCql(searchText) {
|
|
10
|
+
return searchText.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
11
|
+
}
|
|
4
12
|
export class ConfluenceService {
|
|
5
13
|
/**
|
|
6
14
|
* Creates a new ConfluenceService instance
|
|
@@ -68,7 +76,8 @@ export class ConfluenceService {
|
|
|
68
76
|
async searchSpaces(searchText, limit, start, expand) {
|
|
69
77
|
// Create a CQL query that searches for spaces
|
|
70
78
|
// The correct syntax for space search is: type=space AND title ~ "searchText"
|
|
71
|
-
const
|
|
79
|
+
const escapedSearchText = escapeSearchTextForCql(searchText);
|
|
80
|
+
const cql = `type=space AND title ~ "${escapedSearchText}"`;
|
|
72
81
|
return handleApiOperation(() => SearchService.search1(undefined, expand, undefined, limit?.toString(), start?.toString(), undefined, cql), 'Error searching for spaces');
|
|
73
82
|
}
|
|
74
83
|
static validateConfig() {
|
|
@@ -110,7 +119,7 @@ export const confluenceToolSchemas = {
|
|
|
110
119
|
versionComment: z.string().optional().describe("Comment for this version")
|
|
111
120
|
},
|
|
112
121
|
searchSpaces: {
|
|
113
|
-
searchText: z.string().describe("Text to search for in Confluence Data Center space names or descriptions"),
|
|
122
|
+
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)."),
|
|
114
123
|
limit: z.number().optional().describe("Maximum number of results to return"),
|
|
115
124
|
start: z.number().optional().describe("Start index for pagination"),
|
|
116
125
|
expand: z.string().optional().describe("Comma-separated list of properties to expand")
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"confluence-service.js","sourceRoot":"","sources":["../src/confluence-service.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAE,sBAAsB,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,8BAA8B,CAAC;AAC9F,OAAO,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;
|
|
1
|
+
{"version":3,"file":"confluence-service.js","sourceRoot":"","sources":["../src/confluence-service.ts"],"names":[],"mappings":"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"}
|
package/jest.config.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
preset: 'ts-jest/presets/default-esm',
|
|
3
|
+
extensionsToTreatAsEsm: ['.ts'],
|
|
4
|
+
moduleNameMapper: {
|
|
5
|
+
'^(\\.{1,2}/.*)\\.js$': '$1',
|
|
6
|
+
'^@atlassian-dc-mcp/common$': '<rootDir>/../common/src/index.ts',
|
|
7
|
+
},
|
|
8
|
+
transform: {
|
|
9
|
+
'^.+\\.ts$': ['ts-jest', {
|
|
10
|
+
useESM: true,
|
|
11
|
+
}],
|
|
12
|
+
},
|
|
13
|
+
transformIgnorePatterns: [
|
|
14
|
+
'node_modules/(?!(@atlassian-dc-mcp)/)',
|
|
15
|
+
],
|
|
16
|
+
testEnvironment: 'node',
|
|
17
|
+
testMatch: ['**/__tests__/**/*.test.ts'],
|
|
18
|
+
collectCoverageFrom: [
|
|
19
|
+
'src/**/*.ts',
|
|
20
|
+
'!src/**/*.d.ts',
|
|
21
|
+
'!src/**/index.ts',
|
|
22
|
+
],
|
|
23
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atlassian-dc-mcp/confluence",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"main": "build/index.js",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": "./bin/run.js",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"test": "jest --passWithNoTests"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@atlassian-dc-mcp/common": "^0.
|
|
17
|
+
"@atlassian-dc-mcp/common": "^0.12.0",
|
|
18
18
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
19
19
|
"dotenv": "^16.4.7",
|
|
20
20
|
"node-fetch": "^3.3.2",
|
|
@@ -29,5 +29,5 @@
|
|
|
29
29
|
"publishConfig": {
|
|
30
30
|
"access": "public"
|
|
31
31
|
},
|
|
32
|
-
"gitHead": "
|
|
32
|
+
"gitHead": "aa5b3696f257a8fccfc1930d2a46c17ecd695cdc"
|
|
33
33
|
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { ConfluenceService, escapeSearchTextForCql } from '../confluence-service.js';
|
|
2
|
+
import { SearchService } from '../confluence-client/index.js';
|
|
3
|
+
|
|
4
|
+
jest.mock('../confluence-client/index.js', () => ({
|
|
5
|
+
ContentResourceService: {},
|
|
6
|
+
SearchService: {
|
|
7
|
+
search1: jest.fn(),
|
|
8
|
+
},
|
|
9
|
+
OpenAPI: {
|
|
10
|
+
BASE: '',
|
|
11
|
+
TOKEN: '',
|
|
12
|
+
VERSION: '',
|
|
13
|
+
},
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
describe('escapeSearchTextForCql', () => {
|
|
17
|
+
it('returns plain text unchanged', () => {
|
|
18
|
+
expect(escapeSearchTextForCql('hello')).toBe('hello');
|
|
19
|
+
expect(escapeSearchTextForCql('space name')).toBe('space name');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('escapes double quotes', () => {
|
|
23
|
+
expect(escapeSearchTextForCql('say "hello"')).toBe('say \\"hello\\"');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('escapes backslashes first so they cannot escape the following quote', () => {
|
|
27
|
+
expect(escapeSearchTextForCql('\\')).toBe('\\\\');
|
|
28
|
+
expect(escapeSearchTextForCql('path\\to\\space')).toBe('path\\\\to\\\\space');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('escapes backslash then quote correctly (order matters)', () => {
|
|
32
|
+
expect(escapeSearchTextForCql('\\"')).toBe('\\\\\\"');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('escapes quote then backslash correctly', () => {
|
|
36
|
+
expect(escapeSearchTextForCql('"\\')).toBe('\\"\\\\');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('prevents CQL injection via quoted phrase breakout', () => {
|
|
40
|
+
const malicious = '" OR type=page AND text ~ "secret';
|
|
41
|
+
const escaped = escapeSearchTextForCql(malicious);
|
|
42
|
+
expect(escaped).toContain('\\"');
|
|
43
|
+
expect(escaped).not.toBe(malicious);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('double-escaping is not idempotent (call once only)', () => {
|
|
47
|
+
const input = 'foo"bar\\baz';
|
|
48
|
+
const once = escapeSearchTextForCql(input);
|
|
49
|
+
const twice = escapeSearchTextForCql(once);
|
|
50
|
+
expect(twice).not.toBe(once);
|
|
51
|
+
expect(twice).toContain('\\\\');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('handles empty string', () => {
|
|
55
|
+
expect(escapeSearchTextForCql('')).toBe('');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('ConfluenceService.searchSpaces', () => {
|
|
60
|
+
let service: ConfluenceService;
|
|
61
|
+
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
service = new ConfluenceService('test-host', 'test-token');
|
|
64
|
+
jest.clearAllMocks();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('builds CQL with escaped searchText and calls SearchService', async () => {
|
|
68
|
+
(SearchService.search1 as jest.Mock).mockResolvedValue({ results: [] });
|
|
69
|
+
|
|
70
|
+
await service.searchSpaces('my space', 10, 0);
|
|
71
|
+
|
|
72
|
+
expect(SearchService.search1).toHaveBeenCalledWith(
|
|
73
|
+
undefined,
|
|
74
|
+
undefined,
|
|
75
|
+
undefined,
|
|
76
|
+
'10',
|
|
77
|
+
'0',
|
|
78
|
+
undefined,
|
|
79
|
+
'type=space AND title ~ "my space"'
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('escapes quotes in searchText in the CQL passed to the API', async () => {
|
|
84
|
+
(SearchService.search1 as jest.Mock).mockResolvedValue({ results: [] });
|
|
85
|
+
|
|
86
|
+
await service.searchSpaces('say "hello"', 5);
|
|
87
|
+
|
|
88
|
+
expect(SearchService.search1).toHaveBeenCalledWith(
|
|
89
|
+
undefined,
|
|
90
|
+
undefined,
|
|
91
|
+
undefined,
|
|
92
|
+
'5',
|
|
93
|
+
undefined,
|
|
94
|
+
undefined,
|
|
95
|
+
'type=space AND title ~ "say \\"hello\\""'
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('escapes backslashes in searchText in the CQL passed to the API', async () => {
|
|
100
|
+
(SearchService.search1 as jest.Mock).mockResolvedValue({ results: [] });
|
|
101
|
+
|
|
102
|
+
await service.searchSpaces('path\\to\\space', 5);
|
|
103
|
+
|
|
104
|
+
expect(SearchService.search1).toHaveBeenCalledWith(
|
|
105
|
+
undefined,
|
|
106
|
+
undefined,
|
|
107
|
+
undefined,
|
|
108
|
+
'5',
|
|
109
|
+
undefined,
|
|
110
|
+
undefined,
|
|
111
|
+
'type=space AND title ~ "path\\\\to\\\\space"'
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('forwards API errors via handleApiOperation', async () => {
|
|
116
|
+
const err = new Error('API error');
|
|
117
|
+
(SearchService.search1 as jest.Mock).mockRejectedValue(err);
|
|
118
|
+
|
|
119
|
+
const result = await service.searchSpaces('test');
|
|
120
|
+
|
|
121
|
+
expect(result.success).toBe(false);
|
|
122
|
+
expect(result.error).toBeDefined();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -2,6 +2,15 @@ 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
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Escapes user input for safe use inside a CQL quoted string.
|
|
7
|
+
* Escapes backslash first, then double quote, so that neither can break out of the phrase.
|
|
8
|
+
* Only call once per value; double-escaping would over-escape and break the query.
|
|
9
|
+
*/
|
|
10
|
+
export function escapeSearchTextForCql(searchText: string): string {
|
|
11
|
+
return searchText.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
12
|
+
}
|
|
13
|
+
|
|
5
14
|
export interface ConfluenceContent {
|
|
6
15
|
id?: string;
|
|
7
16
|
type: string;
|
|
@@ -91,7 +100,8 @@ export class ConfluenceService {
|
|
|
91
100
|
async searchSpaces(searchText: string, limit?: number, start?: number, expand?: string) {
|
|
92
101
|
// Create a CQL query that searches for spaces
|
|
93
102
|
// The correct syntax for space search is: type=space AND title ~ "searchText"
|
|
94
|
-
const
|
|
103
|
+
const escapedSearchText = escapeSearchTextForCql(searchText);
|
|
104
|
+
const cql = `type=space AND title ~ "${escapedSearchText}"`;
|
|
95
105
|
|
|
96
106
|
return handleApiOperation(() => SearchService.search1(
|
|
97
107
|
undefined,
|
|
@@ -147,7 +157,7 @@ export const confluenceToolSchemas = {
|
|
|
147
157
|
versionComment: z.string().optional().describe("Comment for this version")
|
|
148
158
|
},
|
|
149
159
|
searchSpaces: {
|
|
150
|
-
searchText: z.string().describe("Text to search for in Confluence Data Center space names or descriptions"),
|
|
160
|
+
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)."),
|
|
151
161
|
limit: z.number().optional().describe("Maximum number of results to return"),
|
|
152
162
|
start: z.number().optional().describe("Start index for pagination"),
|
|
153
163
|
expand: z.string().optional().describe("Comma-separated list of properties to expand")
|