@hanhnd/agent-kit 1.0.41 → 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/cli.js CHANGED
@@ -15136,26 +15136,26 @@ var DigestModelRegistryError = class extends Error {
15136
15136
  var DIGEST_MODEL_REGISTRY = {
15137
15137
  ["tiny" /* TINY */]: {
15138
15138
  id: "tiny" /* TINY */,
15139
- ggufUri: "hf:bartowski/Qwen2.5-0.5B-Instruct-GGUF/Qwen2.5-0.5B-Instruct-Q4_K_M.gguf",
15140
- approxSizeBytes: 4e8,
15139
+ ggufUri: "hf:lmstudio-community/Qwen3.5-0.8B-GGUF/Qwen3.5-0.8B-Q4_K_M.gguf",
15140
+ approxSizeBytes: 528e6,
15141
15141
  license: "Apache-2.0",
15142
- sourceUrl: "https://huggingface.co/bartowski/Qwen2.5-0.5B-Instruct-GGUF",
15142
+ sourceUrl: "https://huggingface.co/lmstudio-community/Qwen3.5-0.8B-GGUF",
15143
15143
  enabled: true
15144
15144
  },
15145
15145
  ["base" /* BASE */]: {
15146
15146
  id: "base" /* BASE */,
15147
- ggufUri: "hf:bartowski/Qwen2.5-1.5B-Instruct-GGUF/Qwen2.5-1.5B-Instruct-Q4_K_M.gguf",
15148
- approxSizeBytes: 1e9,
15147
+ ggufUri: "hf:lmstudio-community/Qwen3.5-2B-GGUF/Qwen3.5-2B-Q4_K_M.gguf",
15148
+ approxSizeBytes: 127e7,
15149
15149
  license: "Apache-2.0",
15150
- sourceUrl: "https://huggingface.co/bartowski/Qwen2.5-1.5B-Instruct-GGUF",
15150
+ sourceUrl: "https://huggingface.co/lmstudio-community/Qwen3.5-2B-GGUF",
15151
15151
  enabled: true
15152
15152
  },
15153
15153
  ["large" /* LARGE */]: {
15154
15154
  id: "large" /* LARGE */,
15155
- ggufUri: "hf:bartowski/Qwen2.5-3B-Instruct-GGUF/Qwen2.5-3B-Instruct-Q4_K_M.gguf",
15156
- approxSizeBytes: 19e8,
15155
+ ggufUri: "hf:lmstudio-community/Qwen3.5-4B-GGUF/Qwen3.5-4B-Q4_K_M.gguf",
15156
+ approxSizeBytes: 271e7,
15157
15157
  license: "Apache-2.0",
15158
- sourceUrl: "https://huggingface.co/bartowski/Qwen2.5-3B-Instruct-GGUF",
15158
+ sourceUrl: "https://huggingface.co/lmstudio-community/Qwen3.5-4B-GGUF",
15159
15159
  enabled: true
15160
15160
  }
15161
15161
  };
@@ -15315,7 +15315,8 @@ async function createLlamaLocalDigestProvider(modelId) {
15315
15315
  const response = await withTimeout(
15316
15316
  session.prompt(userContent, {
15317
15317
  maxTokens: LLAMA_MAX_GENERATED_TOKENS,
15318
- temperature: LLAMA_TEMPERATURE
15318
+ temperature: LLAMA_TEMPERATURE,
15319
+ budgets: { thoughtTokens: 0 }
15319
15320
  }),
15320
15321
  options.timeoutMs,
15321
15322
  () => new LlamaLocalDigestProviderError(`Llama provider timed out after ${options.timeoutMs}ms`)
@@ -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>&nbsp;</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;
@@ -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 { sanitize } from '../core/security/index.js';
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
- const DIFF_FILE_THRESHOLD = 50_000;
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 = getCredential('ATLASSIAN_CLOUD_ID');
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 4 non-memory tool names are registered', async () => {
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
  });