mcp-server-bitbucket 0.12.0 → 0.19.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/Dockerfile +9 -1
- package/dist/index.d.ts +16 -0
- package/dist/index.js +838 -139
- package/package.json +14 -12
- package/scripts/check-ca-bundles.cjs +27 -0
- package/smithery.yaml +10 -4
- package/src/auth.ts +330 -0
- package/src/client.ts +215 -123
- package/src/http-input.ts +36 -0
- package/src/index.ts +274 -13
- package/src/oauth-proxy.ts +298 -0
- package/src/prompts.ts +1 -1
- package/src/settings.ts +51 -10
- package/src/tools/commits.ts +65 -33
- package/src/tools/index.ts +0 -3
- package/src/tools/source.ts +23 -0
- package/src/tools/tags.ts +21 -6
- package/tests/auth.test.ts +277 -0
- package/tests/history.test.ts +284 -0
- package/tests/http-handler.test.ts +67 -0
- package/tests/http-input.test.ts +40 -0
- package/tests/network-resilience.test.ts +113 -0
- package/tests/oauth-proxy.test.ts +286 -0
- package/tests/settings.test.ts +110 -0
- package/tests/stdio.test.ts +20 -0
- package/tests/test-remote.mjs +22 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/** SPEC-2/3/4: resumable history, Git diff semantics and bounded recovery. */
|
|
2
|
+
import axios, { AxiosError, type AxiosAdapter, type InternalAxiosRequestConfig } from 'axios';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { BitbucketClient, BitbucketError, resetClient } from '../src/client.js';
|
|
5
|
+
import { resetSettings } from '../src/settings.js';
|
|
6
|
+
import { handlers as commitTools } from '../src/tools/commits.js';
|
|
7
|
+
import { handlers as sourceTools } from '../src/tools/source.js';
|
|
8
|
+
|
|
9
|
+
vi.mock('../src/utils.js', async (importOriginal) => ({
|
|
10
|
+
...await importOriginal<typeof import('../src/utils.js')>(),
|
|
11
|
+
sleep: vi.fn(async () => undefined),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
const ROOT = 'https://api.bitbucket.org/2.0/repositories/test-workspace/repo';
|
|
15
|
+
type Reply = { status?: number; data?: unknown; headers?: Record<string, string>; code?: string; message?: string };
|
|
16
|
+
let calls: InternalAxiosRequestConfig[];
|
|
17
|
+
let replies: Reply[];
|
|
18
|
+
const adapter: AxiosAdapter = async (config) => {
|
|
19
|
+
calls.push(config);
|
|
20
|
+
const reply = replies.shift() ?? { data: { values: [] } };
|
|
21
|
+
if (reply.code) throw new AxiosError(reply.message ?? 'network failure', reply.code, config);
|
|
22
|
+
const response = { status: reply.status ?? 200, data: reply.data, headers: reply.headers ?? {}, config, statusText: '' };
|
|
23
|
+
if (response.status >= 400) throw new AxiosError('request failed', undefined, config, undefined, response);
|
|
24
|
+
return response;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
vi.stubEnv('BITBUCKET_WORKSPACE', 'test-workspace');
|
|
29
|
+
vi.stubEnv('BITBUCKET_EMAIL', 'test@example.com');
|
|
30
|
+
vi.stubEnv('BITBUCKET_API_TOKEN', 'test-token');
|
|
31
|
+
vi.stubEnv('MAX_RETRIES', '2');
|
|
32
|
+
resetSettings();
|
|
33
|
+
resetClient();
|
|
34
|
+
calls = [];
|
|
35
|
+
replies = [];
|
|
36
|
+
axios.defaults.adapter = adapter;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); });
|
|
40
|
+
|
|
41
|
+
describe('SPEC-2 resumable history', () => {
|
|
42
|
+
it('reports missing history instead of silently claiming an empty complete result, then remains usable', async () => {
|
|
43
|
+
replies = [{ status: 404 }, { data: { values: [{ hash: 'ok' }] } }, { status: 404 }];
|
|
44
|
+
await expect(commitTools.list_commits({ repo_slug: 'repo' })).rejects.toThrow(/not found.*404/i);
|
|
45
|
+
expect(await commitTools.list_commits({ repo_slug: 'repo' })).toMatchObject({ commits: [{ hash: 'ok' }], has_more: false });
|
|
46
|
+
expect(await new BitbucketClient().listCommits('repo')).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('reaches old commits and retains cursor page size without dropping entries', async () => {
|
|
50
|
+
const next = ROOT + '/commits?cursor=old&pagelen=2';
|
|
51
|
+
replies = [{ data: { values: [{ hash: 'new' }], next } }, { data: { values: [{ hash: 'old1' }, { hash: 'old2' }] } }];
|
|
52
|
+
const client = new BitbucketClient();
|
|
53
|
+
const first = await client.listCommitsPage('repo', { limit: 2 });
|
|
54
|
+
expect(first).toMatchObject({ has_more: true, next_cursor: next });
|
|
55
|
+
const second = await client.listCommitsPage('repo', { cursor: first.next_cursor!, limit: 1 });
|
|
56
|
+
expect(second.values).toHaveLength(2);
|
|
57
|
+
expect(second.has_more).toBe(false);
|
|
58
|
+
expect(calls[1].url).toBe(next);
|
|
59
|
+
expect(calls[1].params).toBeUndefined();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it.each([
|
|
63
|
+
'https://evil.example/commits', ROOT.replace('https:', 'http:') + '/commits',
|
|
64
|
+
ROOT + '/refs/tags?page=2', ROOT.replace('https://', 'https://user:pass@') + '/commits',
|
|
65
|
+
ROOT + '/commits#fragment', ROOT.replace('api.bitbucket.org', 'api.bitbucket.org:444') + '/commits',
|
|
66
|
+
ROOT + '/other/../commits', ROOT + '/%2e%2e/repo/commits', ROOT + '/commits%2Fother',
|
|
67
|
+
ROOT + '/commits\\other', ROOT.replace('/repo', '/other') + '/commits',
|
|
68
|
+
])('rejects unsafe cursor %s before any request', async cursor => {
|
|
69
|
+
await expect(new BitbucketClient().listCommitsPage('repo', { cursor })).rejects.toThrow(/cursor/i);
|
|
70
|
+
expect(calls).toHaveLength(0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('follows server next links for existing list clients until the requested limit', async () => {
|
|
74
|
+
replies = [{ data: { values: [{ hash: 'a' }], next: ROOT + '/commits?page=2' } },
|
|
75
|
+
{ data: { values: [{ hash: 'b' }, { hash: 'c' }, { hash: 'd' }] } }];
|
|
76
|
+
expect((await new BitbucketClient().listCommits('repo', { limit: 3 })).map(c => c.hash)).toEqual(['a', 'b', 'c']);
|
|
77
|
+
expect(calls).toHaveLength(2);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('rejects repeated pagination links instead of looping', async () => {
|
|
81
|
+
const next = ROOT + '/commits?page=2';
|
|
82
|
+
replies = [{ data: { values: [], next } }, { data: { values: [], next } }];
|
|
83
|
+
await expect(new BitbucketClient().listCommits('repo')).rejects.toThrow(/pagination/i);
|
|
84
|
+
expect(calls).toHaveLength(2);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('SPEC-3 history operations', () => {
|
|
89
|
+
it.each(['', '.', '..', 'release/../main'])('rejects invalid refs %s before requesting', ref => {
|
|
90
|
+
expect(() => new BitbucketClient().getTag('repo', ref)).toThrow(/revision/i);
|
|
91
|
+
expect(calls).toHaveLength(0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('returns file history and tag queries with explicit continuation', async () => {
|
|
95
|
+
const client = new BitbucketClient();
|
|
96
|
+
replies = [{ data: { values: [{ path: 'old.ts', commit: { hash: 'old' } }], next: ROOT + '/filehistory/release%2Fv1/src/a.ts?page=2' } }, { data: { values: [{ name: 'v2' }] } }];
|
|
97
|
+
const history = await client.getFileHistory('repo', 'src/a.ts', 'release/v1');
|
|
98
|
+
expect(history.values[0].path).toBe('old.ts');
|
|
99
|
+
expect(history.has_more).toBe(true);
|
|
100
|
+
expect(calls[0].url).toContain('/filehistory/release%2Fv1/src/a.ts');
|
|
101
|
+
const tags = await client.listTagsPage('repo', { query: 'name ~ "v2"', sort: '-name' });
|
|
102
|
+
expect(tags.values[0].name).toBe('v2');
|
|
103
|
+
expect(calls[1].params).toMatchObject({ q: 'name ~ "v2"', sort: '-name' });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('preserves full file-history commit hashes without inventing absent metadata', async () => {
|
|
107
|
+
const hash = 'abc123'.repeat(6) + '1234';
|
|
108
|
+
replies = [{ data: { values: [{ path: 'old.ts', commit: { type: 'commit', hash, links: {} } }] } }];
|
|
109
|
+
expect(await sourceTools.get_file_history({ repo_slug: 'repo', path: 'src/a.ts' })).toEqual({
|
|
110
|
+
history: [{ path: 'old.ts', commit: { hash } }], has_more: false, next_cursor: null,
|
|
111
|
+
});
|
|
112
|
+
expect(calls).toHaveLength(1);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('serializes repeated include/exclude filters using Bitbucket syntax', async () => {
|
|
116
|
+
await new BitbucketClient().listCommitsPage('repo', { branch: 'main', include: ['release/v2', 'other'], exclude: ['v1', 'old'], path: 'src/a.ts' });
|
|
117
|
+
const query = new URL(axios.getUri(calls[0])).searchParams;
|
|
118
|
+
expect(query.getAll('include')).toEqual(['main', 'release/v2', 'other']);
|
|
119
|
+
expect(query.getAll('exclude')).toEqual(['v1', 'old']);
|
|
120
|
+
expect(query.has('include[]')).toBe(false);
|
|
121
|
+
expect(query.get('path')).toBe('src/a.ts');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('compares base to head without silently discarding files after 50', async () => {
|
|
125
|
+
replies = [{ data: { hash: 'a'.repeat(40) } }, { data: { hash: 'b'.repeat(40) } },
|
|
126
|
+
{ data: { values: Array.from({ length: 75 }, (_, i) => ({ new: { path: `${i}.ts` }, status: 'added', lines_added: 1 })), next: ROOT + '/diffstat/bbbbbbbbbbbb..aaaaaaaaaaaa?page=2' } }];
|
|
127
|
+
const result = await commitTools.compare_commits({ repo_slug: 'repo', base: 'aaaaaaaaaaaa', head: 'bbbbbbbbbbbb', limit: 100 });
|
|
128
|
+
expect(result.files).toHaveLength(75);
|
|
129
|
+
expect(result.has_more).toBe(true);
|
|
130
|
+
expect(calls[2].url).toContain('/diffstat/bbbbbbbbbbbb..aaaaaaaaaaaa');
|
|
131
|
+
expect(calls[2].params.topic).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it.each([
|
|
135
|
+
{ base: 'main', head: 'release/v2' },
|
|
136
|
+
{ base: 'aaaaaaa', head: 'bbbbbbb' },
|
|
137
|
+
])('resolves named and short refs before following canonical diffstat cursors: %j', async refs => {
|
|
138
|
+
const base = 'aaaaaaaaaaaa' + '1'.repeat(28);
|
|
139
|
+
const head = 'bbbbbbbbbbbb' + '2'.repeat(28);
|
|
140
|
+
const next = ROOT + '/diffstat/bbbbbbbbbbbb..aaaaaaaaaaaa?page=2&pagelen=1&topic=false';
|
|
141
|
+
replies = [{ data: { hash: base } }, { data: { hash: head } },
|
|
142
|
+
{ data: { values: [{ new: { path: 'first.ts' } }], next } },
|
|
143
|
+
{ data: { hash: base } }, { data: { hash: head } }, { data: { values: [{ new: { path: 'second.ts' } }] } }];
|
|
144
|
+
const client = new BitbucketClient();
|
|
145
|
+
const first = await client.compareCommitsPage('repo', refs.base, refs.head, { limit: 1 });
|
|
146
|
+
expect(calls[0].url).toBe('repositories/test-workspace/repo/commit/' + encodeURIComponent(refs.base));
|
|
147
|
+
expect(calls[1].url).toBe('repositories/test-workspace/repo/commit/' + encodeURIComponent(refs.head));
|
|
148
|
+
expect(calls[2].url).toContain('/diffstat/bbbbbbbbbbbb..aaaaaaaaaaaa');
|
|
149
|
+
expect(first.next_cursor).toBe(next);
|
|
150
|
+
const second = await client.compareCommitsPage('repo', refs.base, refs.head, { cursor: first.next_cursor! });
|
|
151
|
+
expect(second.values[0]).toMatchObject({ new: { path: 'second.ts' } });
|
|
152
|
+
expect(calls[5].url).toBe(next);
|
|
153
|
+
expect(second.has_more).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('resolves full hashes exactly before canonicalizing, and still rejects unrelated comparison cursors', async () => {
|
|
157
|
+
const base = 'A'.repeat(40);
|
|
158
|
+
const head = 'B'.repeat(40);
|
|
159
|
+
const next = ROOT + '/diffstat/bbbbbbbbbbbb..aaaaaaaaaaaa?page=2';
|
|
160
|
+
replies = [{ data: { hash: base } }, { data: { hash: head } }, { data: { values: [], next } },
|
|
161
|
+
{ data: { hash: base } }, { data: { hash: head } }, { data: { values: [] } },
|
|
162
|
+
{ data: { hash: base } }, { data: { hash: head } }];
|
|
163
|
+
const client = new BitbucketClient();
|
|
164
|
+
const first = await client.compareCommitsPage('repo', base, head);
|
|
165
|
+
await client.compareCommitsPage('repo', base, head, { cursor: first.next_cursor! });
|
|
166
|
+
expect(calls).toHaveLength(6);
|
|
167
|
+
expect(calls[0].url).toContain('/commit/' + base);
|
|
168
|
+
expect(calls[2].url).toContain('/diffstat/bbbbbbbbbbbb..aaaaaaaaaaaa');
|
|
169
|
+
await expect(client.compareCommitsPage('repo', base, head, { cursor: ROOT + '/diffstat/cccccccccccc..aaaaaaaaaaaa?page=2' })).rejects.toThrow(/cursor/i);
|
|
170
|
+
expect(calls).toHaveLength(8);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('does not turn an invalid full SHA into an existing shorter prefix', async () => {
|
|
174
|
+
const invalid = 'aaaaaaaaaaaa' + '0'.repeat(28);
|
|
175
|
+
replies = [{ status: 404 }];
|
|
176
|
+
await expect(new BitbucketClient().compareCommitsPage('repo', invalid, 'main')).rejects.toThrow(/not found.*404/i);
|
|
177
|
+
expect(calls).toHaveLength(1);
|
|
178
|
+
expect(calls[0].url).toContain('/commit/' + invalid);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('resolves hexadecimal branch names to their actual target commit', async () => {
|
|
182
|
+
replies = [{ data: { hash: 'c'.repeat(40) } }, { data: { hash: 'd'.repeat(40) } }, { data: { values: [] } }];
|
|
183
|
+
await new BitbucketClient().compareCommitsPage('repo', 'aaaaaaaaaaaa', 'bbbbbbbbbbbb');
|
|
184
|
+
expect(calls[0].url).toContain('/commit/aaaaaaaaaaaa');
|
|
185
|
+
expect(calls[1].url).toContain('/commit/bbbbbbbbbbbb');
|
|
186
|
+
expect(calls[2].url).toContain('/diffstat/dddddddddddd..cccccccccccc');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('rejects a cursor if a named revision moved between comparison pages', async () => {
|
|
190
|
+
replies = [{ data: { hash: 'c'.repeat(40) } }, { data: { hash: 'b'.repeat(40) } }];
|
|
191
|
+
await expect(new BitbucketClient().compareCommitsPage('repo', 'main', 'release', {
|
|
192
|
+
cursor: ROOT + '/diffstat/bbbbbbbbbbbb..aaaaaaaaaaaa?page=2',
|
|
193
|
+
})).rejects.toThrow(/cursor/i);
|
|
194
|
+
expect(calls).toHaveLength(2);
|
|
195
|
+
expect(calls.every(call => call.url?.includes('/commit/'))).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('encodes slash refs, returns literal text diff, and exposes merge base and tag lookup', async () => {
|
|
199
|
+
const client = new BitbucketClient();
|
|
200
|
+
replies = [{ data: '"literal diff"' }, { data: { hash: 'ancestor' } }, { data: { name: 'release/v1' } }];
|
|
201
|
+
expect(await client.getCommitDiff('repo', 'base', 'feature/new', { path: 'a.ts' })).toBe('"literal diff"');
|
|
202
|
+
expect(calls[0].url).toContain('/diff/feature%2Fnew..base');
|
|
203
|
+
expect(calls[0].responseType).toBe('text');
|
|
204
|
+
expect(calls[0].params.topic).toBe(false);
|
|
205
|
+
expect((await client.getMergeBase('repo', 'base', 'head'))?.hash).toBe('ancestor');
|
|
206
|
+
expect((await client.getTag('repo', 'release/v1'))?.name).toBe('release/v1');
|
|
207
|
+
expect(calls[2].url).toContain('/refs/tags/release%2Fv1');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('exposes clipped diff continuation including a single large file', async () => {
|
|
211
|
+
replies = [{ data: 'abcdef' }, { data: 'abcdef' }];
|
|
212
|
+
const first = await commitTools.get_commit_diff({ repo_slug: 'repo', base: 'a', head: 'b', max_chars: 3 });
|
|
213
|
+
expect(first).toMatchObject({ diff: 'abc', offset: 0, next_offset: 3, truncated: true, total_chars: 6 });
|
|
214
|
+
expect(await commitTools.get_commit_diff({ repo_slug: 'repo', base: 'a', head: 'b', offset: 3, max_chars: 3 }))
|
|
215
|
+
.toMatchObject({ diff: 'def', next_offset: null, truncated: false });
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe('SPEC-4 bounded resilient requests', () => {
|
|
220
|
+
it('does not retry generic bad-response failures such as response-size limits', async () => {
|
|
221
|
+
replies = [{ code: 'ERR_BAD_RESPONSE', message: 'maxContentLength size of 100 exceeded' }, { data: {} }];
|
|
222
|
+
await expect(new BitbucketClient().getRepository('repo')).rejects.toThrow(BitbucketError);
|
|
223
|
+
expect(calls).toHaveLength(1);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it.each([{ code: 'ECONNRESET' }, { code: 'ETIMEDOUT' }, { status: 503 }])('recovers reads from %j', async failure => {
|
|
227
|
+
replies = [failure, { data: { name: 'ok' } }];
|
|
228
|
+
expect(await new BitbucketClient().getRepository('repo')).toMatchObject({ name: 'ok' });
|
|
229
|
+
expect(calls).toHaveLength(2);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('does not replay an ambiguous write', async () => {
|
|
233
|
+
replies = [{ code: 'ECONNRESET' }, { data: {} }];
|
|
234
|
+
await expect(new BitbucketClient().createTag('repo', 'v1', 'main')).rejects.toThrow(BitbucketError);
|
|
235
|
+
expect(calls).toHaveLength(1);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('does not replay writes after server errors either', async () => {
|
|
239
|
+
replies = [{ status: 503 }, { data: {} }];
|
|
240
|
+
await expect(new BitbucketClient().createTag('repo', 'v1', 'main')).rejects.toThrow(BitbucketError);
|
|
241
|
+
expect(calls).toHaveLength(1);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('does not retry authorization failures', async () => {
|
|
245
|
+
replies = [{ status: 401 }];
|
|
246
|
+
await expect(new BitbucketClient().getRepository('repo')).rejects.toThrow(/401/);
|
|
247
|
+
expect(calls).toHaveLength(1);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('honors HTTP-date Retry-After', async () => {
|
|
251
|
+
const header = new Date(Date.now() + 3000).toUTCString();
|
|
252
|
+
replies = [{ status: 429, headers: { 'retry-after': header } }, { data: {} }];
|
|
253
|
+
expect(await new BitbucketClient().getRepository('repo')).toEqual({});
|
|
254
|
+
expect(calls).toHaveLength(2);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('bounds total elapsed retry time and caps each attempt timeout', async () => {
|
|
258
|
+
vi.spyOn(performance, 'now').mockReturnValueOnce(0).mockReturnValueOnce(0).mockReturnValue(31000);
|
|
259
|
+
replies = [{ status: 503 }, { data: {} }];
|
|
260
|
+
await expect(new BitbucketClient().getRepository('repo')).rejects.toThrow(/budget/i);
|
|
261
|
+
expect(calls).toHaveLength(1);
|
|
262
|
+
expect(calls[0].timeout).toBe(30000);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it.each(['bad', '-2', 'nan', 'inf'])('handles malformed Retry-After %s', async header => {
|
|
266
|
+
replies = [{ status: 429, headers: { 'retry-after': header } }, { data: {} }];
|
|
267
|
+
expect(await new BitbucketClient().getRepository('repo')).toEqual({});
|
|
268
|
+
expect(calls).toHaveLength(2);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('fails fast instead of sleeping beyond the retry budget', async () => {
|
|
272
|
+
replies = [{ status: 429, headers: { 'retry-after': '3600' } }];
|
|
273
|
+
await expect(new BitbucketClient().getRepository('repo')).rejects.toThrow(/budget/i);
|
|
274
|
+
expect(calls).toHaveLength(1);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('stays usable after retry exhaustion', async () => {
|
|
278
|
+
replies = [{ code: 'ECONNRESET' }, { code: 'ECONNRESET' }, { code: 'ECONNRESET' }, { data: { name: 'ok' } }];
|
|
279
|
+
const client = new BitbucketClient();
|
|
280
|
+
await expect(client.getRepository('repo')).rejects.toThrow(/Network/i);
|
|
281
|
+
expect(await client.getRepository('repo')).toMatchObject({ name: 'ok' });
|
|
282
|
+
expect(calls).toHaveLength(4);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { Readable } from 'node:stream';
|
|
3
|
+
import { EventEmitter } from 'node:events';
|
|
4
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
5
|
+
|
|
6
|
+
const boundary = vi.hoisted(() => ({
|
|
7
|
+
verify: vi.fn(), forward: vi.fn(), register: vi.fn(), reads: 0,
|
|
8
|
+
}));
|
|
9
|
+
vi.mock('../src/auth.js', async (original) => ({
|
|
10
|
+
...await original<typeof import('../src/auth.js')>(),
|
|
11
|
+
getAuthConfig: () => ({ issuer: 'https://issuer.example', audience: 'mcp', resourceUrl: 'https://mcp.example/mcp' }),
|
|
12
|
+
verifyBearer: boundary.verify,
|
|
13
|
+
wwwAuthenticate: () => 'Bearer resource_metadata="https://mcp.example/.well-known/oauth-protected-resource"',
|
|
14
|
+
}));
|
|
15
|
+
vi.mock('../src/oauth-proxy.js', async (original) => ({
|
|
16
|
+
...await original<typeof import('../src/oauth-proxy.js')>(),
|
|
17
|
+
getProxyConfig: () => ({ origin: 'https://mcp.example' }),
|
|
18
|
+
resolveUpstreamEndpoints: async () => ({ token: 'https://issuer.example/token' }),
|
|
19
|
+
forwardTokenRequest: boundary.forward,
|
|
20
|
+
registerClient: boundary.register,
|
|
21
|
+
}));
|
|
22
|
+
import { createHttpHandler } from '../src/index.js';
|
|
23
|
+
|
|
24
|
+
async function request(url: string, body: Buffer[], authenticated = true) {
|
|
25
|
+
boundary.verify.mockResolvedValue(authenticated ? { ok: true } : {
|
|
26
|
+
ok: false, status: 401, error: 'invalid_token', description: 'Missing token',
|
|
27
|
+
});
|
|
28
|
+
const req = Readable.from((async function* () {
|
|
29
|
+
for (const chunk of body) { boundary.reads++; yield chunk; }
|
|
30
|
+
})()) as unknown as IncomingMessage;
|
|
31
|
+
Object.assign(req, { method: 'POST', url, headers: {}, socket: { remoteAddress: 'test' } });
|
|
32
|
+
const res = new EventEmitter() as ServerResponse;
|
|
33
|
+
let response = '';
|
|
34
|
+
Object.assign(res, {
|
|
35
|
+
statusCode: 200, setHeader: vi.fn(),
|
|
36
|
+
writeHead(status: number) { res.statusCode = status; return res; },
|
|
37
|
+
end(value = '') { response = value.toString(); res.emit('finish'); return res; },
|
|
38
|
+
});
|
|
39
|
+
await createHttpHandler()(req, res);
|
|
40
|
+
return { status: res.statusCode, body: response };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
beforeEach(() => { vi.clearAllMocks(); boundary.reads = 0; });
|
|
44
|
+
describe('real HTTP handler without a socket or upstream service', () => {
|
|
45
|
+
it('checks authentication before consuming an oversized MCP body', async () => {
|
|
46
|
+
expect((await request('/mcp', [Buffer.alloc(110 * 1024)], false)).status).toBe(401);
|
|
47
|
+
expect(boundary.reads).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
it('bounds malformed MCP bytes before JSON parsing and SDK dispatch', async () => {
|
|
50
|
+
expect((await request('/mcp', [Buffer.alloc(110 * 1024, '!')])).status).toBe(413);
|
|
51
|
+
expect(boundary.verify).toHaveBeenCalledOnce();
|
|
52
|
+
});
|
|
53
|
+
it('does not forward an oversized OAuth token request', async () => {
|
|
54
|
+
expect((await request('/oauth/token', [Buffer.alloc(110 * 1024)])).status).toBe(413);
|
|
55
|
+
expect(boundary.forward).not.toHaveBeenCalled();
|
|
56
|
+
});
|
|
57
|
+
it('does not parse an oversized OAuth registration request', async () => {
|
|
58
|
+
expect((await request('/oauth/register', [Buffer.alloc(110 * 1024, '!')])).status).toBe(413);
|
|
59
|
+
expect(boundary.register).not.toHaveBeenCalled();
|
|
60
|
+
});
|
|
61
|
+
it('preserves OAuth token form bytes and upstream response shape', async () => {
|
|
62
|
+
boundary.forward.mockResolvedValue({ status: 200, contentType: 'application/json', body: '{"access_token":"synthetic"}' });
|
|
63
|
+
const result = await request('/oauth/token', [Buffer.from('code=a%2Bb&'), Buffer.from('grant_type=authorization_code')]);
|
|
64
|
+
expect(result).toEqual({ status: 200, body: '{"access_token":"synthetic"}' });
|
|
65
|
+
expect(boundary.forward).toHaveBeenCalledWith('code=a%2Bb&grant_type=authorization_code', undefined, 'https://issuer.example/token');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { Readable } from 'node:stream';
|
|
3
|
+
import { parseMcpBody, readHttpBody } from '../src/http-input.js';
|
|
4
|
+
|
|
5
|
+
describe('HTTP input bounds before OAuth forwarding and SDK validation', () => {
|
|
6
|
+
it('preserves byte-exact OAuth form bodies across chunks', async () => {
|
|
7
|
+
const input = ['grant_type=refresh_token&', 'refresh_token=a%2Bb&scope=mcp'];
|
|
8
|
+
expect((await readHttpBody(Readable.from(input.map(x => Buffer.from(x))))).toString())
|
|
9
|
+
.toBe(input.join(''));
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('stops consuming chunked bodies above 100 KiB', async () => {
|
|
13
|
+
let consumed = 0;
|
|
14
|
+
async function* chunks() {
|
|
15
|
+
for (let i = 0; i < 5; i++) { consumed++; yield Buffer.alloc(40 * 1024); }
|
|
16
|
+
}
|
|
17
|
+
await expect(readHttpBody(chunks())).rejects.toMatchObject({ status: 413 });
|
|
18
|
+
expect(consumed).toBe(3);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('accepts a normal single MCP request without changing its arguments', () => {
|
|
22
|
+
const request = { jsonrpc: '2.0', id: 1, method: 'tools/call', params: {
|
|
23
|
+
name: 'list_repositories', arguments: { limit: 5 },
|
|
24
|
+
} };
|
|
25
|
+
expect(parseMcpBody(Buffer.from(JSON.stringify(request)))).toEqual(request);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('rejects a request with excessive nested tool-input elements before SDK dispatch', () => {
|
|
29
|
+
const request = { jsonrpc: '2.0', id: 1, method: 'tools/call', params: {
|
|
30
|
+
name: 'create_pull_request', arguments: { reviewers: Array.from({ length: 1001 }, () => ({})) },
|
|
31
|
+
} };
|
|
32
|
+
expect(() => parseMcpBody(Buffer.from(JSON.stringify(request))))
|
|
33
|
+
.toThrow(/elements/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('rejects a JSON-RPC batch before dispatch', () => {
|
|
37
|
+
expect(() => parseMcpBody(Buffer.from('[{"jsonrpc":"2.0","id":1,"method":"ping"}]')))
|
|
38
|
+
.toThrow(/batch/i);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/** SPEC-4: actual incomplete HTTP bodies and MCP process survival. */
|
|
2
|
+
import axios, { type AxiosError } from 'axios';
|
|
3
|
+
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
|
4
|
+
import { createServer, type Server, type ServerResponse } from 'node:http';
|
|
5
|
+
import { once } from 'node:events';
|
|
6
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
10
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
11
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
12
|
+
import { BitbucketClient } from '../src/client.js';
|
|
13
|
+
import { resetSettings } from '../src/settings.js';
|
|
14
|
+
|
|
15
|
+
vi.mock('../src/utils.js', async (importOriginal) => ({
|
|
16
|
+
...await importOriginal<typeof import('../src/utils.js')>(), sleep: vi.fn(async () => undefined),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
let server: Server | undefined;
|
|
20
|
+
|
|
21
|
+
function abortBody(response: ServerResponse): void {
|
|
22
|
+
response.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': '1000' });
|
|
23
|
+
response.flushHeaders();
|
|
24
|
+
response.write('{"incomplete":');
|
|
25
|
+
setTimeout(() => response.destroy(), 20);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function listen(handler: Parameters<typeof createServer>[0]): Promise<string> {
|
|
29
|
+
server = createServer(handler);
|
|
30
|
+
server.listen(0, '127.0.0.1');
|
|
31
|
+
await once(server, 'listening');
|
|
32
|
+
const address = server.address();
|
|
33
|
+
if (!address || typeof address === 'string') throw new Error('Missing loopback address');
|
|
34
|
+
return `http://127.0.0.1:${address.port}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
vi.stubEnv('BITBUCKET_WORKSPACE', 'synthetic');
|
|
39
|
+
vi.stubEnv('BITBUCKET_EMAIL', 'test@example.com');
|
|
40
|
+
vi.stubEnv('BITBUCKET_API_TOKEN', 'synthetic-no-provider-call');
|
|
41
|
+
vi.stubEnv('MAX_RETRIES', '1');
|
|
42
|
+
resetSettings();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(async () => {
|
|
46
|
+
vi.restoreAllMocks();
|
|
47
|
+
vi.unstubAllEnvs();
|
|
48
|
+
resetSettings();
|
|
49
|
+
if (server) {
|
|
50
|
+
server.closeAllConnections();
|
|
51
|
+
await new Promise<void>(resolve => server!.close(() => resolve()));
|
|
52
|
+
server = undefined;
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it.each(['json', 'text'])('retries actual aborted %s GET response bodies, but never repeats an aborted write', async format => {
|
|
57
|
+
let reads = 0;
|
|
58
|
+
let writes = 0;
|
|
59
|
+
const observed: AxiosError[] = [];
|
|
60
|
+
const url = await listen((request, response) => {
|
|
61
|
+
if (request.method === 'POST') { writes++; abortBody(response); }
|
|
62
|
+
else if (++reads === 1) abortBody(response);
|
|
63
|
+
else { response.setHeader('Content-Type', 'application/json'); response.end('{"name":"recovered"}'); }
|
|
64
|
+
});
|
|
65
|
+
const originalCreate = axios.create.bind(axios);
|
|
66
|
+
vi.spyOn(axios, 'create').mockImplementation(config => {
|
|
67
|
+
const instance = originalCreate({ ...config, baseURL: url, proxy: false });
|
|
68
|
+
instance.interceptors.response.use(undefined, error => { observed.push(error); return Promise.reject(error); });
|
|
69
|
+
return instance;
|
|
70
|
+
});
|
|
71
|
+
const client = new BitbucketClient();
|
|
72
|
+
if (format === 'json') expect(await client.getRepository('repo')).toMatchObject({ name: 'recovered' });
|
|
73
|
+
else expect(await client.getCommitDiff('repo', 'base', 'head')).toBe('{"name":"recovered"}');
|
|
74
|
+
expect(reads).toBe(2);
|
|
75
|
+
expect(observed[0].code).toBe('ERR_BAD_RESPONSE');
|
|
76
|
+
expect(observed[0].message).toBe('stream has been aborted');
|
|
77
|
+
expect(observed[0].response?.status).toBe(200);
|
|
78
|
+
await expect(client.createTag('repo', 'v1', 'main')).rejects.toThrow(/Network/);
|
|
79
|
+
expect(writes).toBe(1);
|
|
80
|
+
}, 10_000);
|
|
81
|
+
|
|
82
|
+
it('reports exhausted HTTP disconnects, then completes a tool call in the same stdio subprocess', async () => {
|
|
83
|
+
let calls = 0;
|
|
84
|
+
const url = await listen((_request, response) => {
|
|
85
|
+
if (++calls <= 2) abortBody(response);
|
|
86
|
+
else { response.setHeader('Content-Type', 'application/json'); response.end('{"values":[{"hash":"recovered","message":"ok"}]}'); }
|
|
87
|
+
});
|
|
88
|
+
const directory = await mkdtemp(join(tmpdir(), 'bitbucket-stdio-recovery-'));
|
|
89
|
+
const preloader = join(directory, 'loopback.mjs');
|
|
90
|
+
const axiosModule = pathToFileURL(fileURLToPath(new URL('../node_modules/axios/index.js', import.meta.url))).href;
|
|
91
|
+
await writeFile(preloader, `import axios from ${JSON.stringify(axiosModule)};\nconst create = axios.create.bind(axios);\naxios.create = config => create({...config, baseURL:${JSON.stringify(url)}, proxy:false});\n`);
|
|
92
|
+
const client = new Client({ name: 'synthetic-recovery', version: '1.0.0' });
|
|
93
|
+
const transport = new StdioClientTransport({
|
|
94
|
+
command: process.execPath,
|
|
95
|
+
args: ['--import', preloader, fileURLToPath(new URL('../dist/index.js', import.meta.url))],
|
|
96
|
+
env: { BITBUCKET_WORKSPACE: 'synthetic', BITBUCKET_EMAIL: 'test@example.com', BITBUCKET_API_TOKEN: 'synthetic-no-provider-call',
|
|
97
|
+
MAX_RETRIES: '1', API_TIMEOUT: '5', MCP_TRANSPORT: 'stdio' },
|
|
98
|
+
});
|
|
99
|
+
try {
|
|
100
|
+
await client.connect(transport);
|
|
101
|
+
const failed = await client.callTool({ name: 'list_commits', arguments: { repo_slug: 'repo' } });
|
|
102
|
+
expect(failed.isError).toBe(true);
|
|
103
|
+
expect(JSON.stringify(failed.content)).toContain('Network request failed after 2 attempt(s)');
|
|
104
|
+
expect(calls).toBe(2);
|
|
105
|
+
const succeeded = await client.callTool({ name: 'list_commits', arguments: { repo_slug: 'repo' } });
|
|
106
|
+
expect(succeeded.isError).not.toBe(true);
|
|
107
|
+
expect(JSON.stringify(succeeded.content)).toContain('recover');
|
|
108
|
+
expect(calls).toBe(3);
|
|
109
|
+
} finally {
|
|
110
|
+
await client.close();
|
|
111
|
+
await rm(directory, { recursive: true, force: true });
|
|
112
|
+
}
|
|
113
|
+
}, 15_000);
|