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/src/client.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  * - Permissions: user and group permissions
16
16
  */
17
17
 
18
- import axios, { AxiosInstance, AxiosError } from 'axios';
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
- * Bitbucket API client with connection pooling and retry logic
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: settings.apiTimeout * 1000,
81
- auth: {
82
- username: settings.bitbucketEmail,
83
- password: settings.bitbucketApiToken,
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
- * Make an API request with retry logic for rate limiting
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
- url: path,
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
- const axiosError = error as AxiosError;
123
-
124
- // Handle 404 as null (not found)
125
- if (axiosError.response?.status === 404) {
126
- return null;
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
- // Handle rate limiting (429)
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
- // Other errors
147
- const statusCode = axiosError.response?.status;
148
- const errorText = JSON.stringify(axiosError.response?.data || axiosError.message).substring(0, 500);
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
- throw error;
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
- throw new BitbucketError(`Unexpected error in request`, undefined, method, path);
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
- * Make a request that returns plain text
165
- */
166
- private async requestText(path: string): Promise<string | null> {
167
- let backoff = BitbucketClient.INITIAL_BACKOFF;
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
- for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
170
- try {
171
- const response = await this.client.get(path, {
172
- responseType: 'text',
173
- });
174
- return response.data;
175
- } catch (error) {
176
- if (axios.isAxiosError(error)) {
177
- if (error.response?.status === 404) {
178
- return null;
179
- }
180
- if (error.response?.status === 429) {
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
- return null;
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 { limit = 50, maxPage = 100, ...extraParams } = options;
205
- const params: Record<string, unknown> = {
206
- pagelen: Math.min(limit, maxPage),
207
- ...extraParams,
208
- };
209
-
210
- // Filter out undefined values
211
- Object.keys(params).forEach(key => {
212
- if (params[key] === undefined) {
213
- delete params[key];
214
- }
215
- });
216
-
217
- const result = await this.request<PaginatedResponse<T>>('GET', endpoint, undefined, params);
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 || 20,
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
- async compareCommits(
764
- repoSlug: string,
765
- base: string,
766
- head: string
767
- ): Promise<Record<string, unknown> | null> {
768
- return this.request('GET', this.repoPath(repoSlug, 'diffstat', `${base}..${head}`));
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 || 50,
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
+ }