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
package/src/client.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* - Permissions: user and group permissions
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import axios, { AxiosInstance
|
|
18
|
+
import axios, { AxiosInstance } from 'axios';
|
|
19
19
|
import { getSettings } from './settings.js';
|
|
20
20
|
import { ensureUuidBraces, sleep } from './utils.js';
|
|
21
21
|
import type {
|
|
@@ -58,9 +58,27 @@ export class BitbucketError extends Error {
|
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
export interface HistoryPage<T> {
|
|
62
|
+
values: T[];
|
|
63
|
+
has_more: boolean;
|
|
64
|
+
next_cursor: string | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface FileHistoryEntry {
|
|
68
|
+
path: string;
|
|
69
|
+
commit: { hash: string };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface CommitHistoryOptions {
|
|
73
|
+
branch?: string;
|
|
74
|
+
include?: string[];
|
|
75
|
+
exclude?: string[];
|
|
76
|
+
path?: string;
|
|
77
|
+
limit?: number;
|
|
78
|
+
cursor?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Bitbucket API client with bounded retries and resumable history. */
|
|
64
82
|
export class BitbucketClient {
|
|
65
83
|
private static readonly BASE_URL = 'https://api.bitbucket.org/2.0';
|
|
66
84
|
private static readonly INITIAL_BACKOFF = 1000; // ms
|
|
@@ -68,22 +86,34 @@ export class BitbucketClient {
|
|
|
68
86
|
public readonly workspace: string;
|
|
69
87
|
private readonly client: AxiosInstance;
|
|
70
88
|
private readonly maxRetries: number;
|
|
89
|
+
private readonly requestTimeout: number;
|
|
71
90
|
|
|
72
91
|
constructor() {
|
|
73
92
|
const settings = getSettings();
|
|
74
93
|
|
|
75
94
|
this.workspace = settings.bitbucketWorkspace;
|
|
76
95
|
this.maxRetries = settings.maxRetries;
|
|
96
|
+
this.requestTimeout = settings.apiTimeout * 1000;
|
|
97
|
+
|
|
98
|
+
// Access tokens (workspace/project/repository) only authenticate with
|
|
99
|
+
// `Authorization: Bearer`; Atlassian API tokens use Basic email:token.
|
|
100
|
+
const useBearer = settings.bitbucketAuthType === 'bearer';
|
|
77
101
|
|
|
78
102
|
this.client = axios.create({
|
|
79
103
|
baseURL: BitbucketClient.BASE_URL,
|
|
80
|
-
timeout:
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
104
|
+
timeout: this.requestTimeout,
|
|
105
|
+
paramsSerializer: { indexes: null },
|
|
106
|
+
...(useBearer
|
|
107
|
+
? {}
|
|
108
|
+
: {
|
|
109
|
+
auth: {
|
|
110
|
+
username: settings.bitbucketEmail,
|
|
111
|
+
password: settings.bitbucketApiToken,
|
|
112
|
+
},
|
|
113
|
+
}),
|
|
85
114
|
headers: {
|
|
86
115
|
'Content-Type': 'application/json',
|
|
116
|
+
...(useBearer ? { Authorization: `Bearer ${settings.bitbucketApiToken}` } : {}),
|
|
87
117
|
},
|
|
88
118
|
});
|
|
89
119
|
}
|
|
@@ -96,126 +126,137 @@ export class BitbucketClient {
|
|
|
96
126
|
return parts.length > 0 ? `${base}/${parts.join('/')}` : base;
|
|
97
127
|
}
|
|
98
128
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
129
|
+
private encodeRef(ref: string): string {
|
|
130
|
+
if (!ref || ref.split('/').some(part => part === '.' || part === '..')) throw new BitbucketError('Invalid revision name');
|
|
131
|
+
return encodeURIComponent(ref);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private encodeFilePath(path: string): string {
|
|
135
|
+
if (path.startsWith('/') || path.split('/').some(part => part === '.' || part === '..')) {
|
|
136
|
+
throw new BitbucketError('Invalid repository file path');
|
|
137
|
+
}
|
|
138
|
+
return path.split('/').map(encodeURIComponent).join('/');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Retry only reads after ambiguous network/server failures; writes may retry 429. */
|
|
102
142
|
private async request<T>(
|
|
103
143
|
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
|
104
144
|
path: string,
|
|
105
145
|
data?: unknown,
|
|
106
|
-
params?: Record<string, unknown
|
|
146
|
+
params?: Record<string, unknown>,
|
|
147
|
+
text = false
|
|
107
148
|
): Promise<T | null> {
|
|
108
149
|
let backoff = BitbucketClient.INITIAL_BACKOFF;
|
|
109
|
-
|
|
150
|
+
let slept = 0;
|
|
151
|
+
const deadline = performance.now() + this.requestTimeout;
|
|
110
152
|
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
153
|
+
const remaining = deadline - performance.now();
|
|
154
|
+
if (remaining <= 0) throw new BitbucketError('Request retry budget exhausted', undefined, method, path);
|
|
111
155
|
try {
|
|
112
156
|
const response = await this.client.request<T>({
|
|
113
|
-
method,
|
|
114
|
-
|
|
115
|
-
data,
|
|
116
|
-
params,
|
|
157
|
+
method, url: path, data, params, timeout: Math.max(1, Math.floor(remaining)),
|
|
158
|
+
...(text ? { responseType: 'text', transformResponse: [(value: unknown) => value] } : {}),
|
|
117
159
|
});
|
|
118
|
-
|
|
119
160
|
return response.data;
|
|
120
161
|
} catch (error) {
|
|
121
|
-
if (axios.isAxiosError(error))
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
162
|
+
if (!axios.isAxiosError(error)) throw error;
|
|
163
|
+
const status = error.response?.status;
|
|
164
|
+
if (status === 404) return null;
|
|
165
|
+
// Axios can retain the 200 response headers when the body is aborted.
|
|
166
|
+
// Match this specific transport failure, not generic ERR_BAD_RESPONSE
|
|
167
|
+
// errors such as size limits or invalid JSON.
|
|
168
|
+
const abortedBody = error.code === 'ERR_BAD_RESPONSE' && error.message === 'stream has been aborted';
|
|
169
|
+
const network = abortedBody || (!error.response && ['ECONNRESET', 'ECONNREFUSED', 'ECONNABORTED', 'ETIMEDOUT', 'EPIPE', 'EAI_AGAIN', 'ENOTFOUND', 'ERR_NETWORK'].includes(error.code || ''));
|
|
170
|
+
const retryable = status === 429 || (method === 'GET' && (network || [502, 503, 504].includes(status ?? 0)));
|
|
171
|
+
if (retryable && attempt < this.maxRetries) {
|
|
172
|
+
const header = error.response?.headers['retry-after'];
|
|
173
|
+
let wait = backoff;
|
|
174
|
+
if (typeof header === 'string' && header.trim()) {
|
|
175
|
+
const numeric = Number(header);
|
|
176
|
+
const dated = /[a-z]/i.test(header) && !['nan', 'inf', 'infinity'].includes(header.toLowerCase())
|
|
177
|
+
? Date.parse(header) - Date.now() : NaN;
|
|
178
|
+
const parsed = Number.isFinite(numeric) && numeric >= 0 ? numeric * 1000 : dated;
|
|
179
|
+
if (Number.isFinite(parsed) && parsed >= 0) wait = parsed;
|
|
127
180
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (axiosError.response?.status === 429) {
|
|
131
|
-
if (attempt < this.maxRetries) {
|
|
132
|
-
const retryAfter = axiosError.response.headers['retry-after'];
|
|
133
|
-
const waitTime = retryAfter ? parseInt(retryAfter, 10) * 1000 : backoff;
|
|
134
|
-
await sleep(waitTime);
|
|
135
|
-
backoff *= 2;
|
|
136
|
-
continue;
|
|
137
|
-
}
|
|
138
|
-
throw new BitbucketError(
|
|
139
|
-
`Rate limited after ${this.maxRetries} retries`,
|
|
140
|
-
429,
|
|
141
|
-
method,
|
|
142
|
-
path
|
|
143
|
-
);
|
|
181
|
+
if (wait > 15000 - slept || wait >= deadline - performance.now()) {
|
|
182
|
+
throw new BitbucketError('Retry-After exceeds request retry budget; retry later', status, method, path);
|
|
144
183
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
throw new BitbucketError(
|
|
150
|
-
`API error ${statusCode}: ${errorText}`,
|
|
151
|
-
statusCode,
|
|
152
|
-
method,
|
|
153
|
-
path
|
|
154
|
-
);
|
|
184
|
+
await sleep(wait);
|
|
185
|
+
slept += wait;
|
|
186
|
+
backoff *= 2;
|
|
187
|
+
continue;
|
|
155
188
|
}
|
|
156
|
-
|
|
189
|
+
// Do not include request config, credentials, or arbitrary upstream error bodies.
|
|
190
|
+
throw new BitbucketError(
|
|
191
|
+
network ? `Network request failed after ${attempt + 1} attempt(s)` : `API error ${status ?? 'unknown'}`,
|
|
192
|
+
status, method, path
|
|
193
|
+
);
|
|
157
194
|
}
|
|
158
195
|
}
|
|
196
|
+
throw new BitbucketError('Request retry budget exhausted', undefined, method, path);
|
|
197
|
+
}
|
|
159
198
|
|
|
160
|
-
|
|
199
|
+
private requestText(path: string, params?: Record<string, unknown>): Promise<string | null> {
|
|
200
|
+
return this.request<string>('GET', path, undefined, params, true);
|
|
161
201
|
}
|
|
162
202
|
|
|
163
|
-
/**
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
203
|
+
/** A next link can change only query parameters, never the authenticated endpoint. */
|
|
204
|
+
private validateCursor(cursor: string, endpoint: string): string {
|
|
205
|
+
const expected = new URL(endpoint, BitbucketClient.BASE_URL + '/');
|
|
206
|
+
let parsed: URL;
|
|
207
|
+
try { parsed = new URL(cursor); } catch { throw new BitbucketError('Invalid pagination cursor'); }
|
|
208
|
+
const rawPath = cursor.split(/[?#]/, 1)[0].replace(/^https:\/\/[^/]+/, '');
|
|
209
|
+
const segments = rawPath.split('/');
|
|
210
|
+
const unsafeSegments = segments.some(segment => {
|
|
211
|
+
let decoded: string;
|
|
212
|
+
try { decoded = decodeURIComponent(segment); } catch { return true; }
|
|
213
|
+
return decoded === '.' || decoded === '..' || decoded.includes('\\');
|
|
214
|
+
});
|
|
215
|
+
if (parsed.protocol !== 'https:' || parsed.hostname !== 'api.bitbucket.org' ||
|
|
216
|
+
(parsed.port && parsed.port !== '443') || parsed.username || parsed.password || parsed.hash ||
|
|
217
|
+
cursor.includes('\\') || unsafeSegments ||
|
|
218
|
+
parsed.pathname.replace(/\/$/, '') !== expected.pathname.replace(/\/$/, '')) {
|
|
219
|
+
throw new BitbucketError('Invalid pagination cursor for this endpoint');
|
|
220
|
+
}
|
|
221
|
+
return cursor;
|
|
222
|
+
}
|
|
168
223
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if (attempt < this.maxRetries) {
|
|
182
|
-
const retryAfter = error.response.headers['retry-after'];
|
|
183
|
-
const waitTime = retryAfter ? parseInt(retryAfter, 10) * 1000 : backoff;
|
|
184
|
-
await sleep(waitTime);
|
|
185
|
-
backoff *= 2;
|
|
186
|
-
continue;
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
throw new BitbucketError(`Request failed: ${error.response?.status}`);
|
|
190
|
-
}
|
|
191
|
-
throw error;
|
|
192
|
-
}
|
|
224
|
+
private async historyPage<T>(
|
|
225
|
+
endpoint: string,
|
|
226
|
+
options: { limit?: number; cursor?: string; maxPage?: number } & Record<string, unknown> = {},
|
|
227
|
+
allowMissing = false
|
|
228
|
+
): Promise<HistoryPage<T>> {
|
|
229
|
+
const { limit = 50, cursor, maxPage = 100, ...filters } = options;
|
|
230
|
+
const params: Record<string, unknown> = { pagelen: Math.min(Math.max(1, Math.floor(limit)), maxPage), ...filters };
|
|
231
|
+
for (const key of Object.keys(params)) if (params[key] === undefined) delete params[key];
|
|
232
|
+
const result = await this.request<PaginatedResponse<T>>('GET', cursor ? this.validateCursor(cursor, endpoint) : endpoint,
|
|
233
|
+
undefined, cursor ? undefined : params);
|
|
234
|
+
if (result === null && !allowMissing) {
|
|
235
|
+
throw new BitbucketError('History resource or revision not found (404)', 404, 'GET', endpoint);
|
|
193
236
|
}
|
|
194
|
-
|
|
237
|
+
const next = result?.next ? this.validateCursor(result.next, endpoint) : null;
|
|
238
|
+
// Never slice a server page: a resumed cursor retains the original page size.
|
|
239
|
+
return { values: result?.values ?? [], has_more: next !== null, next_cursor: next };
|
|
195
240
|
}
|
|
196
241
|
|
|
197
|
-
/**
|
|
198
|
-
* Helper for paginated list endpoints
|
|
199
|
-
*/
|
|
200
242
|
private async paginatedList<T>(
|
|
201
243
|
endpoint: string,
|
|
202
244
|
options: { limit?: number; maxPage?: number } & Record<string, unknown> = {}
|
|
203
245
|
): Promise<T[]> {
|
|
204
|
-
const
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
if (
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
return result?.values || [];
|
|
246
|
+
const limit = Math.max(1, Math.floor(options.limit ?? 50));
|
|
247
|
+
const values: T[] = [];
|
|
248
|
+
const visited = new Set<string>();
|
|
249
|
+
let cursor: string | undefined;
|
|
250
|
+
// Bounded even if an upstream endpoint produces empty or cyclic pages.
|
|
251
|
+
for (let pageCount = 0; pageCount < 1000; pageCount++) {
|
|
252
|
+
const page = await this.historyPage<T>(endpoint, { ...options, cursor }, true);
|
|
253
|
+
values.push(...page.values);
|
|
254
|
+
if (values.length >= limit || !page.next_cursor) return values.slice(0, limit);
|
|
255
|
+
if (visited.has(page.next_cursor)) throw new BitbucketError('Repeated pagination cursor');
|
|
256
|
+
visited.add(page.next_cursor);
|
|
257
|
+
cursor = page.next_cursor;
|
|
258
|
+
}
|
|
259
|
+
throw new BitbucketError('Pagination page budget exhausted');
|
|
219
260
|
}
|
|
220
261
|
|
|
221
262
|
// ==================== REPOSITORIES ====================
|
|
@@ -740,32 +781,74 @@ export class BitbucketClient {
|
|
|
740
781
|
}
|
|
741
782
|
|
|
742
783
|
async getBranch(repoSlug: string, branchName: string): Promise<BitbucketBranch | null> {
|
|
743
|
-
return this.request('GET', this.repoPath(repoSlug, 'refs', 'branches', branchName));
|
|
784
|
+
return this.request('GET', this.repoPath(repoSlug, 'refs', 'branches', this.encodeRef(branchName)));
|
|
744
785
|
}
|
|
745
786
|
|
|
746
787
|
// ==================== COMMITS ====================
|
|
747
788
|
|
|
748
|
-
async listCommits(
|
|
749
|
-
repoSlug: string,
|
|
750
|
-
options: { branch?: string; path?: string; limit?: number } = {}
|
|
751
|
-
): Promise<BitbucketCommit[]> {
|
|
789
|
+
async listCommits(repoSlug: string, options: CommitHistoryOptions = {}): Promise<BitbucketCommit[]> {
|
|
752
790
|
return this.paginatedList(this.repoPath(repoSlug, 'commits'), {
|
|
753
|
-
limit: options.limit
|
|
754
|
-
include: options.branch,
|
|
755
|
-
path: options.path,
|
|
791
|
+
limit: options.limit ?? 20,
|
|
792
|
+
include: [...(options.branch ? [options.branch] : []), ...(options.include ?? [])],
|
|
793
|
+
exclude: options.exclude, path: options.path,
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
listCommitsPage(repoSlug: string, options: CommitHistoryOptions = {}): Promise<HistoryPage<BitbucketCommit>> {
|
|
798
|
+
return this.historyPage(this.repoPath(repoSlug, 'commits'), {
|
|
799
|
+
limit: options.limit ?? 20, cursor: options.cursor,
|
|
800
|
+
include: [...(options.branch ? [options.branch] : []), ...(options.include ?? [])],
|
|
801
|
+
exclude: options.exclude, path: options.path,
|
|
756
802
|
});
|
|
757
803
|
}
|
|
758
804
|
|
|
759
805
|
async getCommit(repoSlug: string, commit: string): Promise<BitbucketCommit | null> {
|
|
760
|
-
return this.request('GET', this.repoPath(repoSlug, 'commit', commit));
|
|
806
|
+
return this.request('GET', this.repoPath(repoSlug, 'commit', this.encodeRef(commit)));
|
|
761
807
|
}
|
|
762
808
|
|
|
763
|
-
|
|
764
|
-
repoSlug:
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
809
|
+
compareCommits(repoSlug: string, base: string, head: string): Promise<Record<string, unknown> | null> {
|
|
810
|
+
return this.request('GET', this.repoPath(repoSlug, 'diffstat', this.revisionRange(base, head)), undefined, { topic: false });
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
private async resolveComparisonRef(repoSlug: string, ref: string): Promise<string> {
|
|
814
|
+
this.encodeRef(ref);
|
|
815
|
+
// Bitbucket rewrites diffstat next links to 12-character commit hashes.
|
|
816
|
+
// Canonicalize before validating the cursor so it remains endpoint-bound.
|
|
817
|
+
// Resolve even hexadecimal names: they may be legal branch/tag names,
|
|
818
|
+
// and an invalid full SHA must not become valid by truncating its prefix.
|
|
819
|
+
const commit = await this.getCommit(repoSlug, ref);
|
|
820
|
+
if (!commit) throw new BitbucketError('Commit or revision not found (404)', 404, 'GET');
|
|
821
|
+
if (!/^[a-fA-F0-9]{12,40}$/.test(commit.hash)) throw new BitbucketError('Invalid commit hash returned by Bitbucket');
|
|
822
|
+
return commit.hash.slice(0, 12).toLowerCase();
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
async compareCommitsPage(repoSlug: string, base: string, head: string,
|
|
826
|
+
options: { path?: string; limit?: number; cursor?: string } = {}): Promise<HistoryPage<Record<string, unknown>>> {
|
|
827
|
+
const canonicalBase = await this.resolveComparisonRef(repoSlug, base);
|
|
828
|
+
const canonicalHead = await this.resolveComparisonRef(repoSlug, head);
|
|
829
|
+
return this.historyPage(this.repoPath(repoSlug, 'diffstat', this.revisionRange(canonicalBase, canonicalHead)), {
|
|
830
|
+
limit: options.limit ?? 50, cursor: options.cursor, path: options.path, topic: false,
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/** Bitbucket takes the source first, the opposite of git diff base head. */
|
|
835
|
+
private revisionRange(base: string, head: string): string {
|
|
836
|
+
return `${this.encodeRef(head)}..${this.encodeRef(base)}`;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
getCommitDiff(repoSlug: string, base: string, head: string, options: { path?: string } = {}): Promise<string | null> {
|
|
840
|
+
return this.requestText(this.repoPath(repoSlug, 'diff', this.revisionRange(base, head)), { topic: false, path: options.path });
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
getMergeBase(repoSlug: string, base: string, head: string): Promise<BitbucketCommit | null> {
|
|
844
|
+
return this.request('GET', this.repoPath(repoSlug, 'merge-base', this.revisionRange(base, head)));
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
getFileHistory(repoSlug: string, path: string, ref = 'main', options: { limit?: number; cursor?: string } = {}): Promise<HistoryPage<FileHistoryEntry>> {
|
|
848
|
+
if (!path) throw new BitbucketError('File history requires a file path');
|
|
849
|
+
return this.historyPage(this.repoPath(repoSlug, 'filehistory', this.encodeRef(ref), this.encodeFilePath(path)), {
|
|
850
|
+
limit: options.limit ?? 20, cursor: options.cursor,
|
|
851
|
+
});
|
|
769
852
|
}
|
|
770
853
|
|
|
771
854
|
async getCommitStatuses(
|
|
@@ -903,13 +986,22 @@ export class BitbucketClient {
|
|
|
903
986
|
|
|
904
987
|
// ==================== TAGS ====================
|
|
905
988
|
|
|
906
|
-
async listTags(repoSlug: string, options: { limit?: number } = {}): Promise<BitbucketTag[]> {
|
|
989
|
+
async listTags(repoSlug: string, options: { limit?: number; query?: string; sort?: string } = {}): Promise<BitbucketTag[]> {
|
|
907
990
|
return this.paginatedList(this.repoPath(repoSlug, 'refs', 'tags'), {
|
|
908
|
-
limit: options.limit
|
|
909
|
-
sort: '-target.date',
|
|
991
|
+
limit: options.limit ?? 50, sort: options.sort ?? '-target.date', q: options.query,
|
|
910
992
|
});
|
|
911
993
|
}
|
|
912
994
|
|
|
995
|
+
listTagsPage(repoSlug: string, options: { limit?: number; query?: string; sort?: string; cursor?: string } = {}): Promise<HistoryPage<BitbucketTag>> {
|
|
996
|
+
return this.historyPage(this.repoPath(repoSlug, 'refs', 'tags'), {
|
|
997
|
+
limit: options.limit ?? 50, cursor: options.cursor, sort: options.sort ?? '-target.date', q: options.query,
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
getTag(repoSlug: string, tagName: string): Promise<(BitbucketTag & { date?: string }) | null> {
|
|
1002
|
+
return this.request('GET', this.repoPath(repoSlug, 'refs', 'tags', this.encodeRef(tagName)));
|
|
1003
|
+
}
|
|
1004
|
+
|
|
913
1005
|
async createTag(
|
|
914
1006
|
repoSlug: string,
|
|
915
1007
|
name: string,
|
|
@@ -936,7 +1028,7 @@ export class BitbucketClient {
|
|
|
936
1028
|
}
|
|
937
1029
|
|
|
938
1030
|
async deleteTag(repoSlug: string, tagName: string): Promise<void> {
|
|
939
|
-
await this.request('DELETE', this.repoPath(repoSlug, 'refs', 'tags', tagName));
|
|
1031
|
+
await this.request('DELETE', this.repoPath(repoSlug, 'refs', 'tags', this.encodeRef(tagName)));
|
|
940
1032
|
}
|
|
941
1033
|
|
|
942
1034
|
// ==================== BRANCH RESTRICTIONS ====================
|
|
@@ -999,7 +1091,7 @@ export class BitbucketClient {
|
|
|
999
1091
|
path: string,
|
|
1000
1092
|
ref: string = 'main'
|
|
1001
1093
|
): Promise<string | null> {
|
|
1002
|
-
return this.requestText(this.repoPath(repoSlug, 'src', ref, path));
|
|
1094
|
+
return this.requestText(this.repoPath(repoSlug, 'src', this.encodeRef(ref), this.encodeFilePath(path)));
|
|
1003
1095
|
}
|
|
1004
1096
|
|
|
1005
1097
|
async listDirectory(
|
|
@@ -1008,8 +1100,8 @@ export class BitbucketClient {
|
|
|
1008
1100
|
options: { ref?: string; limit?: number } = {}
|
|
1009
1101
|
): Promise<DirectoryEntry[]> {
|
|
1010
1102
|
const endpoint = path
|
|
1011
|
-
? this.repoPath(repoSlug, 'src', options.ref || 'main', path)
|
|
1012
|
-
: this.repoPath(repoSlug, 'src', options.ref || 'main');
|
|
1103
|
+
? this.repoPath(repoSlug, 'src', this.encodeRef(options.ref || 'main'), this.encodeFilePath(path))
|
|
1104
|
+
: this.repoPath(repoSlug, 'src', this.encodeRef(options.ref || 'main'));
|
|
1013
1105
|
return this.paginatedList(endpoint, { limit: options.limit || 100 });
|
|
1014
1106
|
}
|
|
1015
1107
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Bounds apply before OAuth forwarding or SDK validation, including chunked bodies. */
|
|
2
|
+
export class HttpInputError extends Error {
|
|
3
|
+
constructor(message: string, readonly status = 400) { super(message); }
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export async function readHttpBody(source: AsyncIterable<Uint8Array>): Promise<Buffer> {
|
|
7
|
+
const chunks: Buffer[] = [];
|
|
8
|
+
let bytes = 0;
|
|
9
|
+
for await (const chunk of source) {
|
|
10
|
+
bytes += chunk.byteLength;
|
|
11
|
+
if (bytes > 100 * 1024) throw new HttpInputError('Request body exceeds 100 KiB', 413);
|
|
12
|
+
chunks.push(Buffer.from(chunk));
|
|
13
|
+
}
|
|
14
|
+
return Buffer.concat(chunks);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function parseMcpBody(body: Buffer): unknown {
|
|
18
|
+
const parsed: unknown = JSON.parse(body.toString('utf8'));
|
|
19
|
+
if (Array.isArray(parsed)) throw new HttpInputError('JSON-RPC batches are not supported');
|
|
20
|
+
// This server uses the SDK's low-level Server, so McpServer's opt-in
|
|
21
|
+
// maxToolInputElements option does not protect its dispatch path.
|
|
22
|
+
if (parsed && typeof parsed === 'object' && 'method' in parsed && parsed.method === 'tools/call') {
|
|
23
|
+
const pending: unknown[] = [parsed];
|
|
24
|
+
let elements = 0;
|
|
25
|
+
while (pending.length) {
|
|
26
|
+
const value = pending.pop();
|
|
27
|
+
if (value && typeof value === 'object') {
|
|
28
|
+
const values = Object.values(value);
|
|
29
|
+
elements += values.length;
|
|
30
|
+
if (elements > 1000) throw new HttpInputError('Tool input exceeds 1000 elements');
|
|
31
|
+
pending.push(...values);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return parsed;
|
|
36
|
+
}
|