@hanhnd/agent-kit 1.0.42 → 1.0.43
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/dist/mcp/confluence.test.d.ts +1 -0
- package/dist/mcp/confluence.test.js +258 -0
- package/dist/mcp/integration.d.ts +44 -2
- package/dist/mcp/integration.js +166 -9
- package/dist/mcp/jira.test.d.ts +1 -0
- package/dist/mcp/jira.test.js +37 -0
- package/dist/mcp/registration.test.js +12 -1
- package/dist/server.js +614 -17
- package/dist/services/integration/credentials.d.ts +1 -1
- package/dist/services/integration/credentials.js +1 -1
- package/dist/services/integration/parser/storage.d.ts +18 -0
- package/dist/services/integration/parser/storage.js +508 -0
- package/dist/services/integration/parser/storage.test.d.ts +1 -0
- package/dist/services/integration/parser/storage.test.js +211 -0
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import * as assert from 'node:assert/strict';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import { afterEach, describe, mock, test } from 'node:test';
|
|
4
|
+
import { LARGE_PAYLOAD_THRESHOLD, handleConfluenceGetPage, resolveConfluencePageId } from './integration.js';
|
|
5
|
+
const PAGE_ID = '123456789';
|
|
6
|
+
const PAGE_URL = `https://acme.atlassian.net/wiki/spaces/ENG/pages/${PAGE_ID}/Some+Page+Title`;
|
|
7
|
+
const BLOG_URL = 'https://acme.atlassian.net/wiki/spaces/ENG/blog/2026/01/15/987654321/Post+Title';
|
|
8
|
+
const TINY_URL = 'https://acme.atlassian.net/wiki/x/AbCdEf';
|
|
9
|
+
// Credentials resolve through process.env first, which is enough for these tests: every
|
|
10
|
+
// case here supplies credentials rather than asserting their absence.
|
|
11
|
+
process.env.ATLASSIAN_CLOUD_ID = 'cloud-a';
|
|
12
|
+
process.env.ATLASSIAN_USER_EMAIL = 'dev@acme.test';
|
|
13
|
+
process.env.CONFLUENCE_API_TOKEN = 'confluence-token';
|
|
14
|
+
/** Replace the global fetch for one test; `afterEach` restores it. */
|
|
15
|
+
function stubFetch(status, body) {
|
|
16
|
+
const calls = [];
|
|
17
|
+
const authHeaders = [];
|
|
18
|
+
mock.method(globalThis, 'fetch', async (input, init) => {
|
|
19
|
+
calls.push(String(input));
|
|
20
|
+
authHeaders.push(String(init?.headers?.Authorization ?? ''));
|
|
21
|
+
return new Response(typeof body === 'string' ? body : JSON.stringify(body), {
|
|
22
|
+
status,
|
|
23
|
+
headers: { 'content-type': 'application/json' },
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
return { calls, authHeaders };
|
|
27
|
+
}
|
|
28
|
+
const spilledFiles = [];
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
mock.restoreAll();
|
|
31
|
+
while (spilledFiles.length > 0) {
|
|
32
|
+
const file = spilledFiles.pop();
|
|
33
|
+
if (file)
|
|
34
|
+
fs.rmSync(file, { force: true });
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
/** Decode the `email:token` pair a Basic auth header carries. */
|
|
38
|
+
function decodeBasicAuth(header) {
|
|
39
|
+
return Buffer.from(header.replace('Basic ', ''), 'base64').toString('utf8');
|
|
40
|
+
}
|
|
41
|
+
function pageFixture(overrides = {}) {
|
|
42
|
+
return {
|
|
43
|
+
id: PAGE_ID,
|
|
44
|
+
title: 'Design Notes',
|
|
45
|
+
status: 'current',
|
|
46
|
+
spaceId: '98765',
|
|
47
|
+
version: { number: 7, createdAt: '2026-07-01T10:00:00.000Z' },
|
|
48
|
+
body: { storage: { value: '<p>Hello <strong>world</strong></p>' } },
|
|
49
|
+
labels: { results: [{ name: 'design' }, { name: 'api' }] },
|
|
50
|
+
_links: { base: 'https://acme.atlassian.net/wiki', webui: `/spaces/ENG/pages/${PAGE_ID}/Design+Notes` },
|
|
51
|
+
...overrides,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
async function textFor(input) {
|
|
55
|
+
return (await handleConfluenceGetPage({ input })).content[0].text;
|
|
56
|
+
}
|
|
57
|
+
async function successText(page) {
|
|
58
|
+
stubFetch(200, page);
|
|
59
|
+
return textFor(PAGE_URL);
|
|
60
|
+
}
|
|
61
|
+
describe('resolveConfluencePageId (BC2, BC5, BC6, BC7)', () => {
|
|
62
|
+
test('H1: full page URL with a title segment resolves to the numeric id', () => {
|
|
63
|
+
assert.deepEqual(resolveConfluencePageId(PAGE_URL), { kind: 'id', pageId: PAGE_ID });
|
|
64
|
+
});
|
|
65
|
+
test('H2: page URL with a query string and fragment but no title segment resolves', () => {
|
|
66
|
+
const url = `https://acme.atlassian.net/wiki/spaces/ENG/pages/${PAGE_ID}?focusedCommentId=99#anchor`;
|
|
67
|
+
assert.deepEqual(resolveConfluencePageId(url), { kind: 'id', pageId: PAGE_ID });
|
|
68
|
+
});
|
|
69
|
+
test('H3: legacy viewpage.action form resolves from the pageId query parameter', () => {
|
|
70
|
+
const url = `https://acme.atlassian.net/pages/viewpage.action?pageId=${PAGE_ID}`;
|
|
71
|
+
assert.deepEqual(resolveConfluencePageId(url), { kind: 'id', pageId: PAGE_ID });
|
|
72
|
+
});
|
|
73
|
+
test('H4: bare numeric id resolves', () => {
|
|
74
|
+
assert.deepEqual(resolveConfluencePageId(PAGE_ID), { kind: 'id', pageId: PAGE_ID });
|
|
75
|
+
});
|
|
76
|
+
test('H8: blog URL is classified as blog, never as an id from its date segments', () => {
|
|
77
|
+
const resolution = resolveConfluencePageId(BLOG_URL);
|
|
78
|
+
assert.equal(resolution.kind, 'blog');
|
|
79
|
+
assert.notEqual(resolution.kind, 'id');
|
|
80
|
+
const serialised = JSON.stringify(resolution);
|
|
81
|
+
assert.ok(!serialised.includes('2026'), 'a date segment must never become a page id');
|
|
82
|
+
assert.ok(!serialised.includes('987654321'), 'a blog post id must never become a page id');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
describe('handleConfluenceGetPage — pre-network error paths (BC5, BC6, BC7, BC8)', () => {
|
|
86
|
+
test('H5: tiny link errors before any network call and asks for the full page URL', async () => {
|
|
87
|
+
assert.deepEqual(resolveConfluencePageId(TINY_URL), { kind: 'tiny' });
|
|
88
|
+
const { calls } = stubFetch(200, pageFixture());
|
|
89
|
+
const text = await textFor(TINY_URL);
|
|
90
|
+
assert.equal(calls.length, 0, 'MUST NOT call fetch for a tiny link');
|
|
91
|
+
assert.match(text, /tiny link/i);
|
|
92
|
+
assert.match(text, /full page URL/i);
|
|
93
|
+
});
|
|
94
|
+
test('H6: blog URL errors before any network call', async () => {
|
|
95
|
+
const { calls } = stubFetch(200, pageFixture());
|
|
96
|
+
const text = await textFor(BLOG_URL);
|
|
97
|
+
assert.equal(calls.length, 0, 'MUST NOT call fetch for a blog URL');
|
|
98
|
+
assert.match(text, /blog posts are not supported/i);
|
|
99
|
+
});
|
|
100
|
+
test('H7: unrecognised input errors before any network call and lists accepted forms', async () => {
|
|
101
|
+
assert.deepEqual(resolveConfluencePageId('not a confluence link at all'), { kind: 'unknown' });
|
|
102
|
+
const { calls } = stubFetch(200, pageFixture());
|
|
103
|
+
const text = await textFor('not a confluence link at all');
|
|
104
|
+
assert.equal(calls.length, 0, 'MUST NOT call fetch for unrecognised input');
|
|
105
|
+
assert.match(text, /Accepted input forms/);
|
|
106
|
+
assert.match(text, /viewpage\.action\?pageId=/);
|
|
107
|
+
assert.match(text, /numeric page ID/);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
describe('handleConfluenceGetPage — happy path output (BC3)', () => {
|
|
111
|
+
test('H13: renders the full brief in exactly one request', async () => {
|
|
112
|
+
const { calls, authHeaders } = stubFetch(200, pageFixture());
|
|
113
|
+
const text = await textFor(PAGE_URL);
|
|
114
|
+
assert.equal(calls.length, 1, 'exactly one HTTP request — no space or user lookup');
|
|
115
|
+
assert.ok(calls[0].includes('/ex/confluence/cloud-a/'), `must use the shared cloud id, got: ${calls[0]}`);
|
|
116
|
+
assert.equal(decodeBasicAuth(authHeaders[0]), 'dev@acme.test:confluence-token', 'Confluence must authenticate with the shared email and its own token, not the Jira token');
|
|
117
|
+
assert.ok(calls[0].includes('body-format=storage'), `got: ${calls[0]}`);
|
|
118
|
+
assert.ok(calls[0].includes('include-labels=true'), `got: ${calls[0]}`);
|
|
119
|
+
assert.ok(calls[0].includes('include-version=true'), `got: ${calls[0]}`);
|
|
120
|
+
assert.match(text, /Design Notes/);
|
|
121
|
+
assert.match(text, /\*\*Status:\*\* current/);
|
|
122
|
+
assert.match(text, /\*\*Space:\*\* ENG/);
|
|
123
|
+
assert.match(text, /\*\*Version:\*\* 7/);
|
|
124
|
+
assert.match(text, /\*\*Last updated:\*\* 2026-07-01T10:00:00\.000Z/);
|
|
125
|
+
assert.match(text, new RegExp(`\\*\\*URL:\\*\\* https://acme\\.atlassian\\.net/wiki/spaces/ENG/pages/${PAGE_ID}/Design\\+Notes`));
|
|
126
|
+
assert.match(text, /### Content\nHello \*\*world\*\*/);
|
|
127
|
+
assert.match(text, /### Labels\ndesign, api/);
|
|
128
|
+
});
|
|
129
|
+
test('H14: space line is omitted, never guessed, when webui has no /spaces/<KEY>/', async () => {
|
|
130
|
+
const text = await successText(pageFixture({ _links: { base: 'https://acme.atlassian.net/wiki', webui: '/foo/bar' } }));
|
|
131
|
+
assert.ok(!text.includes('**Space:**'), `space line must be omitted, got: ${text}`);
|
|
132
|
+
assert.ok(!text.includes('98765'), 'spaceId must never be presented as a space key');
|
|
133
|
+
assert.match(text, /\*\*URL:\*\* https:\/\/acme\.atlassian\.net\/wiki\/foo\/bar/);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
describe('handleConfluenceGetPage — distinct HTTP and schema errors (BC9)', () => {
|
|
137
|
+
async function errorText(status, body = { message: 'nope' }) {
|
|
138
|
+
stubFetch(status, body);
|
|
139
|
+
return textFor(PAGE_URL);
|
|
140
|
+
}
|
|
141
|
+
test('H15: 401 identifies an auth failure', async () => {
|
|
142
|
+
const text = await errorText(401);
|
|
143
|
+
assert.match(text, /401/);
|
|
144
|
+
assert.match(text, /Auth failed/i);
|
|
145
|
+
assert.ok(!/Not found/i.test(text), '401 must be distinct from the 404 message');
|
|
146
|
+
});
|
|
147
|
+
test('H16: 403 mentions permission or restriction and is distinct from 404', async () => {
|
|
148
|
+
const text = await errorText(403);
|
|
149
|
+
assert.match(text, /403/);
|
|
150
|
+
assert.match(text, /permission|restricted/i);
|
|
151
|
+
assert.ok(!/Not found/i.test(text), '403 must be distinct from the 404 message');
|
|
152
|
+
});
|
|
153
|
+
test('H17: 404 identifies not-found and is distinct from 403', async () => {
|
|
154
|
+
const text = await errorText(404);
|
|
155
|
+
assert.match(text, /Not found/i);
|
|
156
|
+
assert.ok(!text.includes('403'), '404 must be distinct from the 403 message');
|
|
157
|
+
});
|
|
158
|
+
test('H18: other non-OK statuses include the status code', async () => {
|
|
159
|
+
const text = await errorText(500);
|
|
160
|
+
assert.match(text, /500/);
|
|
161
|
+
assert.ok(!/Auth failed/i.test(text), '500 must be distinct from 401');
|
|
162
|
+
assert.ok(!/Not found/i.test(text), '500 must be distinct from 404');
|
|
163
|
+
assert.ok(!/permission|restricted/i.test(text), '500 must be distinct from 403');
|
|
164
|
+
});
|
|
165
|
+
test('H19: a 200 missing title is reported as an invalid response format', async () => {
|
|
166
|
+
const { title: _title, ...withoutTitle } = pageFixture();
|
|
167
|
+
const text = await successText(withoutTitle);
|
|
168
|
+
assert.match(text, /Invalid Confluence response format/);
|
|
169
|
+
assert.ok(!text.includes('### Content'), 'a schema failure must not be presented as partial success');
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
describe('handleConfluenceGetPage — empty and unconvertible bodies (BC10)', () => {
|
|
173
|
+
const EXPECTED_URL = `https://acme.atlassian.net/wiki/spaces/ENG/pages/${PAGE_ID}/Design+Notes`;
|
|
174
|
+
function assertEmptyWarning(text, status) {
|
|
175
|
+
assert.match(text, /⚠️ Confluence returned no readable body/);
|
|
176
|
+
assert.ok(text.includes(`status: ${status}`), `warning must name the page status, got: ${text}`);
|
|
177
|
+
assert.ok(text.includes(EXPECTED_URL), `warning must include the absolute URL, got: ${text}`);
|
|
178
|
+
assert.ok(!/### Content\n\s*\n/.test(text), 'MUST NOT emit an empty Content section as if it were success');
|
|
179
|
+
}
|
|
180
|
+
test('H20: body: {} (the documented Atlassian defect) warns explicitly', async () => {
|
|
181
|
+
assertEmptyWarning(await successText(pageFixture({ body: {} })), 'current');
|
|
182
|
+
});
|
|
183
|
+
test('H21: an empty storage value warns explicitly', async () => {
|
|
184
|
+
assertEmptyWarning(await successText(pageFixture({ body: { storage: { value: '' } } })), 'current');
|
|
185
|
+
});
|
|
186
|
+
test('H22: a body that converts to whitespace only warns explicitly', async () => {
|
|
187
|
+
assertEmptyWarning(await successText(pageFixture({ body: { storage: { value: '<p> </p>' } } })), 'current');
|
|
188
|
+
});
|
|
189
|
+
test('H23: a trashed page names its status in the warning', async () => {
|
|
190
|
+
const text = await successText(pageFixture({ status: 'trashed', body: {} }));
|
|
191
|
+
assertEmptyWarning(text, 'trashed');
|
|
192
|
+
assert.match(text, /\*\*Status:\*\* trashed/);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
describe('handleConfluenceGetPage — oversize bodies spill to a temp file (BC11)', () => {
|
|
196
|
+
const HUGE_TEXT = 'x'.repeat(LARGE_PAYLOAD_THRESHOLD + 10);
|
|
197
|
+
test('H24: the body is written to /tmp and referenced, not inlined', async () => {
|
|
198
|
+
stubFetch(200, pageFixture({ body: { storage: { value: `<p>${HUGE_TEXT}</p>` } } }));
|
|
199
|
+
const text = await textFor(PAGE_URL);
|
|
200
|
+
const spilledPath = text.match(new RegExp(`/tmp/kit-confluence-${PAGE_ID}-\\d+\\.md`))?.[0];
|
|
201
|
+
assert.ok(spilledPath, `output must reference a temp path, got: ${text.slice(0, 300)}`);
|
|
202
|
+
spilledFiles.push(spilledPath);
|
|
203
|
+
assert.ok(fs.existsSync(spilledPath), 'the temp file must actually be written');
|
|
204
|
+
assert.ok(fs.readFileSync(spilledPath, 'utf8').includes(HUGE_TEXT), 'the temp file must hold the markdown');
|
|
205
|
+
assert.ok(!text.includes(HUGE_TEXT), 'MUST NOT inline the body once it has been spilled');
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
describe('handleConfluenceGetPage — secret redaction and labels (BC19, BC20)', () => {
|
|
209
|
+
test('H26: a pasted AWS key in the page body is redacted', async () => {
|
|
210
|
+
const secret = 'AKIAABCDEFGHIJKLMNOP';
|
|
211
|
+
const text = await successText(pageFixture({ body: { storage: { value: `<p>key ${secret} here</p>` } } }));
|
|
212
|
+
assert.match(text, /\[REDACTED\]/);
|
|
213
|
+
assert.ok(!text.includes(secret), 'the raw token MUST NOT reach the agent context');
|
|
214
|
+
});
|
|
215
|
+
test('H27: absent labels render None', async () => {
|
|
216
|
+
const { labels: _labels, ...withoutLabels } = pageFixture();
|
|
217
|
+
assert.match(await successText(withoutLabels), /### Labels\nNone/);
|
|
218
|
+
});
|
|
219
|
+
test('H28: an empty labels result renders None', async () => {
|
|
220
|
+
assert.match(await successText(pageFixture({ labels: { results: [] } })), /### Labels\nNone/);
|
|
221
|
+
});
|
|
222
|
+
test('H29: multiple labels render comma-joined', async () => {
|
|
223
|
+
assert.match(await successText(pageFixture()), /### Labels\ndesign, api/);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
describe('handleConfluenceGetPage — never rejects (H30)', () => {
|
|
227
|
+
test('H30: every error and success scenario resolves to a valid mcpText envelope', async () => {
|
|
228
|
+
const scenarios = [
|
|
229
|
+
{ label: 'tiny link', input: TINY_URL, status: 200, body: pageFixture() },
|
|
230
|
+
{ label: 'blog URL', input: BLOG_URL, status: 200, body: pageFixture() },
|
|
231
|
+
{ label: 'unrecognised', input: 'nonsense', status: 200, body: pageFixture() },
|
|
232
|
+
...[401, 403, 404, 500].map((status) => ({
|
|
233
|
+
label: `HTTP ${status}`,
|
|
234
|
+
input: PAGE_URL,
|
|
235
|
+
status,
|
|
236
|
+
body: { message: 'nope' },
|
|
237
|
+
})),
|
|
238
|
+
{ label: 'schema mismatch', input: PAGE_URL, status: 200, body: { id: PAGE_ID } },
|
|
239
|
+
{ label: 'empty body', input: PAGE_URL, status: 200, body: pageFixture({ body: {} }) },
|
|
240
|
+
{
|
|
241
|
+
label: 'malformed body markup',
|
|
242
|
+
input: PAGE_URL,
|
|
243
|
+
status: 200,
|
|
244
|
+
body: pageFixture({ body: { storage: { value: '<p><strong>oops</p><![CDATA[' } } }),
|
|
245
|
+
},
|
|
246
|
+
{ label: 'happy path', input: PAGE_URL, status: 200, body: pageFixture() },
|
|
247
|
+
];
|
|
248
|
+
for (const scenario of scenarios) {
|
|
249
|
+
stubFetch(scenario.status, scenario.body);
|
|
250
|
+
const result = await handleConfluenceGetPage({ input: scenario.input });
|
|
251
|
+
assert.equal(result.content.length, 1, `${scenario.label}: expected one content entry`);
|
|
252
|
+
assert.equal(result.content[0].type, 'text', `${scenario.label}: expected a text envelope`);
|
|
253
|
+
assert.equal(typeof result.content[0].text, 'string', `${scenario.label}: expected string text`);
|
|
254
|
+
assert.ok(result.content[0].text.length > 0, `${scenario.label}: expected a non-empty message`);
|
|
255
|
+
mock.restoreAll();
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
});
|
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Integration Tools - Bitbucket, Jira
|
|
3
|
-
* Tools: kit_get_bitbucket_pr, kit_jira_get_ticket
|
|
2
|
+
* Integration Tools - Bitbucket, Jira, Confluence
|
|
3
|
+
* Tools: kit_get_bitbucket_pr, kit_jira_get_ticket, kit_confluence_get_page
|
|
4
4
|
*/
|
|
5
5
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
|
+
/**
|
|
7
|
+
* Payload size (chars) at or above which a body is spilled to a temp file
|
|
8
|
+
* instead of inlined into the agent context.
|
|
9
|
+
*/
|
|
10
|
+
export declare const LARGE_PAYLOAD_THRESHOLD = 50000;
|
|
11
|
+
export type ConfluenceIdResolution = {
|
|
12
|
+
kind: 'id';
|
|
13
|
+
pageId: string;
|
|
14
|
+
} | {
|
|
15
|
+
kind: 'tiny';
|
|
16
|
+
} | {
|
|
17
|
+
kind: 'blog';
|
|
18
|
+
} | {
|
|
19
|
+
kind: 'unknown';
|
|
20
|
+
};
|
|
21
|
+
export interface AtlassianContext {
|
|
22
|
+
auth: string;
|
|
23
|
+
cloudId: string;
|
|
24
|
+
}
|
|
25
|
+
/** Resolve the shared cloud id plus the product's basic-auth header (shared email, own token). */
|
|
26
|
+
export declare function buildAtlassianContext(product: 'jira' | 'confluence'): AtlassianContext;
|
|
27
|
+
/**
|
|
28
|
+
* Resolve a user-supplied Confluence reference to a numeric page id.
|
|
29
|
+
*
|
|
30
|
+
* The blog branch MUST stay ahead of numeric extraction: a blog URL carries date and
|
|
31
|
+
* post-id segments that generic extraction would misread as a page id, silently
|
|
32
|
+
* fetching the wrong content.
|
|
33
|
+
*/
|
|
34
|
+
export declare function resolveConfluencePageId(input: string): ConfluenceIdResolution;
|
|
35
|
+
/**
|
|
36
|
+
* Handler for kit_confluence_get_page. Exported (rather than living inside the
|
|
37
|
+
* registerTool closure) so behaviour is unit testable without a transport or network.
|
|
38
|
+
* Never rejects — every failure path resolves to an actionable mcpText message.
|
|
39
|
+
*/
|
|
40
|
+
export declare function handleConfluenceGetPage(args: {
|
|
41
|
+
input: string;
|
|
42
|
+
}): Promise<{
|
|
43
|
+
content: [{
|
|
44
|
+
type: 'text';
|
|
45
|
+
text: string;
|
|
46
|
+
}];
|
|
47
|
+
}>;
|
|
6
48
|
export declare function registerIntegrationTools(server: McpServer): void;
|
package/dist/mcp/integration.js
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Integration Tools - Bitbucket, Jira
|
|
3
|
-
* Tools: kit_get_bitbucket_pr, kit_jira_get_ticket
|
|
2
|
+
* Integration Tools - Bitbucket, Jira, Confluence
|
|
3
|
+
* Tools: kit_get_bitbucket_pr, kit_jira_get_ticket, kit_confluence_get_page
|
|
4
4
|
*/
|
|
5
5
|
import { writeFileSync } from 'fs';
|
|
6
6
|
import { z } from 'zod';
|
|
7
7
|
import { getCredential } from '../services/integration/credentials.js';
|
|
8
8
|
import { mcpText } from '../utils/utils.js';
|
|
9
9
|
import { adfToMarkdown } from '../services/integration/parser/adf.js';
|
|
10
|
-
import {
|
|
10
|
+
import { storageToMarkdown } from '../services/integration/parser/storage.js';
|
|
11
|
+
import { sanitize, sanitizeOutput } from '../core/security/index.js';
|
|
12
|
+
/**
|
|
13
|
+
* Payload size (chars) at or above which a body is spilled to a temp file
|
|
14
|
+
* instead of inlined into the agent context.
|
|
15
|
+
*/
|
|
16
|
+
export const LARGE_PAYLOAD_THRESHOLD = 50_000;
|
|
11
17
|
// Zod schema for Bitbucket PR REST API response
|
|
12
18
|
const BitbucketPrSchema = z.object({
|
|
13
19
|
id: z.number(),
|
|
@@ -55,6 +61,34 @@ const JiraTicketSchema = z.object({
|
|
|
55
61
|
errorMessages: z.array(z.string()).optional(),
|
|
56
62
|
fields: JiraFieldsSchema,
|
|
57
63
|
});
|
|
64
|
+
// Confluence Cloud REST v2 page schema. Deliberately permissive: only the fields the
|
|
65
|
+
// output renders are required, so an additive Atlassian change cannot break the tool.
|
|
66
|
+
const ConfluencePageSchema = z.object({
|
|
67
|
+
id: z.string(),
|
|
68
|
+
title: z.string(),
|
|
69
|
+
status: z.string().optional(),
|
|
70
|
+
spaceId: z.string().optional(),
|
|
71
|
+
version: z
|
|
72
|
+
.object({
|
|
73
|
+
number: z.number().optional(),
|
|
74
|
+
createdAt: z.string().optional(),
|
|
75
|
+
message: z.string().optional(),
|
|
76
|
+
minorEdit: z.boolean().optional(),
|
|
77
|
+
authorId: z.string().optional(),
|
|
78
|
+
})
|
|
79
|
+
.optional(),
|
|
80
|
+
body: z
|
|
81
|
+
.object({
|
|
82
|
+
storage: z.object({ value: z.string().optional() }).passthrough().optional(),
|
|
83
|
+
})
|
|
84
|
+
.passthrough()
|
|
85
|
+
.optional(),
|
|
86
|
+
labels: z
|
|
87
|
+
.object({ results: z.array(z.object({ name: z.string() }).passthrough()).optional() })
|
|
88
|
+
.passthrough()
|
|
89
|
+
.optional(),
|
|
90
|
+
_links: z.object({ base: z.string().optional(), webui: z.string().optional() }).passthrough().optional(),
|
|
91
|
+
});
|
|
58
92
|
function buildBasicAuth(emailVar, tokenVar) {
|
|
59
93
|
const email = getCredential(emailVar);
|
|
60
94
|
const token = getCredential(tokenVar);
|
|
@@ -62,16 +96,134 @@ function buildBasicAuth(emailVar, tokenVar) {
|
|
|
62
96
|
throw new Error(`Missing ${emailVar} or ${tokenVar}`);
|
|
63
97
|
return 'Basic ' + Buffer.from(`${email}:${token}`).toString('base64');
|
|
64
98
|
}
|
|
99
|
+
// Jira and Confluence share one site and one account, so they share ATLASSIAN_CLOUD_ID
|
|
100
|
+
// and ATLASSIAN_USER_EMAIL — but Atlassian issues scoped API tokens per app, so each
|
|
101
|
+
// product carries its own token.
|
|
102
|
+
const PRODUCT_TOKEN_KEY = {
|
|
103
|
+
jira: 'JIRA_API_TOKEN',
|
|
104
|
+
confluence: 'CONFLUENCE_API_TOKEN',
|
|
105
|
+
};
|
|
106
|
+
/** Resolve the shared cloud id plus the product's basic-auth header (shared email, own token). */
|
|
107
|
+
export function buildAtlassianContext(product) {
|
|
108
|
+
const cloudId = getCredential('ATLASSIAN_CLOUD_ID');
|
|
109
|
+
if (!cloudId)
|
|
110
|
+
throw new Error('Missing ATLASSIAN_CLOUD_ID');
|
|
111
|
+
return { auth: buildBasicAuth('ATLASSIAN_USER_EMAIL', PRODUCT_TOKEN_KEY[product]), cloudId };
|
|
112
|
+
}
|
|
65
113
|
async function callRestApi(url, auth, accept = 'application/json') {
|
|
66
114
|
const resp = await fetch(url, { headers: { Authorization: auth, Accept: accept } });
|
|
67
115
|
if (resp.status === 401)
|
|
68
116
|
throw new Error(`❌ Auth failed (401): ${url}`);
|
|
117
|
+
if (resp.status === 403)
|
|
118
|
+
throw new Error(`❌ Access denied (403): ${url}\n\nYou lack permission for this resource, or it is restricted.`);
|
|
69
119
|
if (resp.status === 404)
|
|
70
120
|
throw new Error(`❌ Not found: ${url}`);
|
|
71
121
|
if (!resp.ok)
|
|
72
122
|
throw new Error(`❌ API error ${resp.status}: ${await resp.text()}`);
|
|
73
123
|
return accept === 'text/plain' ? resp.text() : resp.json();
|
|
74
124
|
}
|
|
125
|
+
const CONFLUENCE_INPUT_FORMS = [
|
|
126
|
+
'Accepted input forms:',
|
|
127
|
+
' • https://<site>.atlassian.net/wiki/spaces/<SPACEKEY>/pages/<pageId>/<Title>',
|
|
128
|
+
' • https://<site>.atlassian.net/pages/viewpage.action?pageId=<pageId>',
|
|
129
|
+
' • a bare numeric page ID (e.g. 123456789)',
|
|
130
|
+
].join('\n');
|
|
131
|
+
/**
|
|
132
|
+
* Resolve a user-supplied Confluence reference to a numeric page id.
|
|
133
|
+
*
|
|
134
|
+
* The blog branch MUST stay ahead of numeric extraction: a blog URL carries date and
|
|
135
|
+
* post-id segments that generic extraction would misread as a page id, silently
|
|
136
|
+
* fetching the wrong content.
|
|
137
|
+
*/
|
|
138
|
+
export function resolveConfluencePageId(input) {
|
|
139
|
+
const trimmed = input.trim();
|
|
140
|
+
if (!trimmed)
|
|
141
|
+
return { kind: 'unknown' };
|
|
142
|
+
if (/\/blog(?:posts?)?\//.test(trimmed))
|
|
143
|
+
return { kind: 'blog' };
|
|
144
|
+
if (/\/wiki\/x\/[A-Za-z0-9]+/.test(trimmed))
|
|
145
|
+
return { kind: 'tiny' };
|
|
146
|
+
const spacesPageMatch = trimmed.match(/\/wiki\/spaces\/[^/]+\/pages\/(\d+)(?:[/?#]|$)/);
|
|
147
|
+
if (spacesPageMatch)
|
|
148
|
+
return { kind: 'id', pageId: spacesPageMatch[1] };
|
|
149
|
+
const queryMatch = trimmed.match(/[?&]pageId=(\d+)(?:[&#]|$)/);
|
|
150
|
+
if (queryMatch)
|
|
151
|
+
return { kind: 'id', pageId: queryMatch[1] };
|
|
152
|
+
if (/^\d+$/.test(trimmed))
|
|
153
|
+
return { kind: 'id', pageId: trimmed };
|
|
154
|
+
return { kind: 'unknown' };
|
|
155
|
+
}
|
|
156
|
+
function buildConfluenceHeader(page, absoluteUrl) {
|
|
157
|
+
const webui = page._links?.webui ?? '';
|
|
158
|
+
const spaceKey = webui.match(/\/spaces\/([^/]+)\//)?.[1];
|
|
159
|
+
const lines = [`## 📄 ${page.title}`, '', `**Status:** ${page.status || 'Unknown'}`];
|
|
160
|
+
if (spaceKey)
|
|
161
|
+
lines.push(`**Space:** ${spaceKey}`);
|
|
162
|
+
lines.push(`**Version:** ${page.version?.number ?? 'Unknown'}`);
|
|
163
|
+
lines.push(`**Last updated:** ${page.version?.createdAt || 'Unknown'}`);
|
|
164
|
+
lines.push(`**URL:** ${absoluteUrl || 'Unknown'}`);
|
|
165
|
+
return lines.join('\n');
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Handler for kit_confluence_get_page. Exported (rather than living inside the
|
|
169
|
+
* registerTool closure) so behaviour is unit testable without a transport or network.
|
|
170
|
+
* Never rejects — every failure path resolves to an actionable mcpText message.
|
|
171
|
+
*/
|
|
172
|
+
export async function handleConfluenceGetPage(args) {
|
|
173
|
+
try {
|
|
174
|
+
const resolution = resolveConfluencePageId(args.input);
|
|
175
|
+
if (resolution.kind === 'tiny') {
|
|
176
|
+
return mcpText(`❌ Confluence tiny links cannot be resolved without following a redirect.\n\nOpen the link in a browser and pass the full page URL instead.\n\n${CONFLUENCE_INPUT_FORMS}`);
|
|
177
|
+
}
|
|
178
|
+
if (resolution.kind === 'blog') {
|
|
179
|
+
return mcpText(`❌ Confluence blog posts are not supported — they live on a different endpoint than pages.\n\n${CONFLUENCE_INPUT_FORMS}`);
|
|
180
|
+
}
|
|
181
|
+
if (resolution.kind === 'unknown') {
|
|
182
|
+
return mcpText(`❌ Could not extract a Confluence page ID from: ${args.input}\n\n${CONFLUENCE_INPUT_FORMS}`);
|
|
183
|
+
}
|
|
184
|
+
const { auth, cloudId } = buildAtlassianContext('confluence');
|
|
185
|
+
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${resolution.pageId}?body-format=storage&include-labels=true&include-version=true`;
|
|
186
|
+
const jsonData = await callRestApi(url, auth);
|
|
187
|
+
const parseResult = ConfluencePageSchema.safeParse(jsonData);
|
|
188
|
+
if (!parseResult.success) {
|
|
189
|
+
return mcpText(`❌ Invalid Confluence response format: ${parseResult.error.message}`);
|
|
190
|
+
}
|
|
191
|
+
const page = parseResult.data;
|
|
192
|
+
const base = page._links?.base ?? '';
|
|
193
|
+
const webui = page._links?.webui ?? '';
|
|
194
|
+
const absoluteUrl = base && webui ? `${base}${webui}` : base || webui;
|
|
195
|
+
const markdown = storageToMarkdown(page.body?.storage?.value);
|
|
196
|
+
let contentSection;
|
|
197
|
+
if (!markdown.trim()) {
|
|
198
|
+
contentSection = `⚠️ Confluence returned no readable body for this page (status: ${page.status || 'Unknown'}). Open ${absoluteUrl || 'the page in Confluence'} to view it directly.`;
|
|
199
|
+
}
|
|
200
|
+
else if (markdown.length >= LARGE_PAYLOAD_THRESHOLD) {
|
|
201
|
+
const filePath = `/tmp/kit-confluence-${resolution.pageId}-${Date.now()}.md`;
|
|
202
|
+
try {
|
|
203
|
+
writeFileSync(filePath, sanitizeOutput(markdown), 'utf8');
|
|
204
|
+
contentSection = `Content is large (${markdown.length} chars). Full markdown written to: \`${filePath}\`. Read this file before proceeding.`;
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
contentSection = `⚠️ Could not write page content to a temp file. Showing inline (may be very large).\n\n${markdown}`;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
contentSection = markdown;
|
|
212
|
+
}
|
|
213
|
+
const labels = page.labels?.results?.map((label) => label.name).filter((name) => name.length > 0) ?? [];
|
|
214
|
+
const output = `${buildConfluenceHeader(page, absoluteUrl)}
|
|
215
|
+
|
|
216
|
+
### Content
|
|
217
|
+
${contentSection}
|
|
218
|
+
|
|
219
|
+
### Labels
|
|
220
|
+
${labels.length > 0 ? labels.join(', ') : 'None'}`;
|
|
221
|
+
return mcpText(sanitizeOutput(output));
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
return mcpText(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
75
227
|
export function registerIntegrationTools(server) {
|
|
76
228
|
// TOOL: GET BITBUCKET PR
|
|
77
229
|
server.registerTool('kit_get_bitbucket_pr', {
|
|
@@ -128,8 +280,7 @@ ${pr.description || 'No description'}`;
|
|
|
128
280
|
if (includeDiff) {
|
|
129
281
|
const diffUrl = `https://api.bitbucket.org/2.0/repositories/${safeWs}/${safeRepo}/pullrequests/${prId}/diff`;
|
|
130
282
|
const diff = (await callRestApi(diffUrl, auth, 'text/plain'));
|
|
131
|
-
|
|
132
|
-
if (diff.length < DIFF_FILE_THRESHOLD) {
|
|
283
|
+
if (diff.length < LARGE_PAYLOAD_THRESHOLD) {
|
|
133
284
|
output += `\n\n### Diff\n\`\`\`diff\n${diff}\n\`\`\``;
|
|
134
285
|
}
|
|
135
286
|
else {
|
|
@@ -163,10 +314,7 @@ ${pr.description || 'No description'}`;
|
|
|
163
314
|
if (!safeTicketId) {
|
|
164
315
|
return mcpText(`❌ Invalid ticket ID format: ${ticketId}\n\nExpected format: PROJ-123`);
|
|
165
316
|
}
|
|
166
|
-
const cloudId =
|
|
167
|
-
if (!cloudId)
|
|
168
|
-
throw new Error('Missing ATLASSIAN_CLOUD_ID');
|
|
169
|
-
const auth = buildBasicAuth('ATLASSIAN_USER_EMAIL', 'ATLASSIAN_API_TOKEN');
|
|
317
|
+
const { auth, cloudId } = buildAtlassianContext('jira');
|
|
170
318
|
const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${safeTicketId}`;
|
|
171
319
|
const jsonData = await callRestApi(url, auth);
|
|
172
320
|
const parseResult = JiraTicketSchema.safeParse(jsonData);
|
|
@@ -196,4 +344,13 @@ ${ticket.fields.labels?.join(', ') || 'None'}`;
|
|
|
196
344
|
return mcpText(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
|
197
345
|
}
|
|
198
346
|
});
|
|
347
|
+
// TOOL: CONFLUENCE GET PAGE
|
|
348
|
+
server.registerTool('kit_confluence_get_page', {
|
|
349
|
+
title: 'Get Confluence Page',
|
|
350
|
+
description: 'Get a Confluence page as markdown using the Atlassian REST API. Accepts a full Confluence page URL or a numeric page ID.',
|
|
351
|
+
inputSchema: {
|
|
352
|
+
input: z.string().describe('Confluence page URL or numeric page ID'),
|
|
353
|
+
},
|
|
354
|
+
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
|
|
355
|
+
}, async ({ input }) => handleConfluenceGetPage({ input }));
|
|
199
356
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import * as assert from 'node:assert/strict';
|
|
2
|
+
import { describe, test } from 'node:test';
|
|
3
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
5
|
+
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
|
6
|
+
import { buildAtlassianContext, registerIntegrationTools } from './integration.js';
|
|
7
|
+
// Both products' tokens are present so J1 can prove they share an email but not a token.
|
|
8
|
+
process.env.ATLASSIAN_CLOUD_ID = 'cloud-a';
|
|
9
|
+
process.env.ATLASSIAN_USER_EMAIL = 'dev@acme.test';
|
|
10
|
+
process.env.JIRA_API_TOKEN = 'token-123';
|
|
11
|
+
process.env.CONFLUENCE_API_TOKEN = 'confluence-token';
|
|
12
|
+
async function buildClient() {
|
|
13
|
+
const server = new McpServer({ name: 'test-kit', version: '0.0.0' });
|
|
14
|
+
registerIntegrationTools(server);
|
|
15
|
+
const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair();
|
|
16
|
+
const client = new Client({ name: 'test-client', version: '0.0.0' });
|
|
17
|
+
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
|
|
18
|
+
return client;
|
|
19
|
+
}
|
|
20
|
+
describe('kit_jira_get_ticket regression after the buildAtlassianContext refactor (BC21)', () => {
|
|
21
|
+
test('J1: jira and confluence share the cloud id and email but not the token', () => {
|
|
22
|
+
const jira = buildAtlassianContext('jira');
|
|
23
|
+
const confluence = buildAtlassianContext('confluence');
|
|
24
|
+
const decode = (auth) => Buffer.from(auth.replace('Basic ', ''), 'base64').toString('utf8');
|
|
25
|
+
assert.equal(jira.cloudId, 'cloud-a');
|
|
26
|
+
assert.equal(confluence.cloudId, 'cloud-a', 'both products must use the same ATLASSIAN_CLOUD_ID');
|
|
27
|
+
assert.equal(decode(jira.auth), 'dev@acme.test:token-123');
|
|
28
|
+
assert.equal(decode(confluence.auth), 'dev@acme.test:confluence-token');
|
|
29
|
+
assert.notEqual(jira.auth, confluence.auth, 'a per-app scoped token must never cross products');
|
|
30
|
+
});
|
|
31
|
+
test('J2: an invalid ticket ID still returns the pre-existing format message', async () => {
|
|
32
|
+
const client = await buildClient();
|
|
33
|
+
const result = await client.callTool({ name: 'kit_jira_get_ticket', arguments: { ticketId: 'not-a-ticket' } });
|
|
34
|
+
const content = result.content;
|
|
35
|
+
assert.equal(content[0].text, '❌ Invalid ticket ID format: not-a-ticket\n\nExpected format: PROJ-123');
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -11,6 +11,7 @@ const EXPECTED_NON_MEMORY_TOOLS = [
|
|
|
11
11
|
'kit_trigger_agent',
|
|
12
12
|
'kit_get_bitbucket_pr',
|
|
13
13
|
'kit_jira_get_ticket',
|
|
14
|
+
'kit_confluence_get_page',
|
|
14
15
|
];
|
|
15
16
|
async function buildClientWithTools() {
|
|
16
17
|
const server = new McpServer({ name: 'test-kit', version: '0.0.0' });
|
|
@@ -23,7 +24,7 @@ async function buildClientWithTools() {
|
|
|
23
24
|
return client;
|
|
24
25
|
}
|
|
25
26
|
describe('MCP tool registration smoke tests', () => {
|
|
26
|
-
test('R1: all
|
|
27
|
+
test('R1: all 5 non-memory tool names are registered', async () => {
|
|
27
28
|
const client = await buildClientWithTools();
|
|
28
29
|
const { tools } = await client.listTools();
|
|
29
30
|
const names = tools.map((t) => t.name);
|
|
@@ -74,4 +75,14 @@ describe('MCP tool registration smoke tests', () => {
|
|
|
74
75
|
assert.equal(tool.annotations.idempotentHint, true, 'idempotentHint must be true');
|
|
75
76
|
assert.equal(tool.annotations.openWorldHint, true, 'openWorldHint must be true');
|
|
76
77
|
});
|
|
78
|
+
test('R6: kit_confluence_get_page (readOnlyHint:true) has correct annotations (BC1)', async () => {
|
|
79
|
+
const client = await buildClientWithTools();
|
|
80
|
+
const { tools } = await client.listTools();
|
|
81
|
+
const tool = tools.find((t) => t.name === 'kit_confluence_get_page');
|
|
82
|
+
assert.ok(tool, 'kit_confluence_get_page must be registered');
|
|
83
|
+
assert.ok(tool.annotations, 'kit_confluence_get_page must have annotations');
|
|
84
|
+
assert.equal(tool.annotations.readOnlyHint, true, 'readOnlyHint must be true');
|
|
85
|
+
assert.equal(tool.annotations.idempotentHint, true, 'idempotentHint must be true');
|
|
86
|
+
assert.equal(tool.annotations.openWorldHint, true, 'openWorldHint must be true');
|
|
87
|
+
});
|
|
77
88
|
});
|