mcp-server-bitbucket 0.11.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 {
@@ -40,6 +40,7 @@ import type {
40
40
  PaginatedResponse,
41
41
  TriggerPipelineOptions,
42
42
  PipelineTriggerVariable,
43
+ BitbucketPipelineConfig,
43
44
  } from './types.js';
44
45
 
45
46
  /**
@@ -57,9 +58,27 @@ export class BitbucketError extends Error {
57
58
  }
58
59
  }
59
60
 
60
- /**
61
- * Bitbucket API client with connection pooling and retry logic
62
- */
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. */
63
82
  export class BitbucketClient {
64
83
  private static readonly BASE_URL = 'https://api.bitbucket.org/2.0';
65
84
  private static readonly INITIAL_BACKOFF = 1000; // ms
@@ -67,22 +86,34 @@ export class BitbucketClient {
67
86
  public readonly workspace: string;
68
87
  private readonly client: AxiosInstance;
69
88
  private readonly maxRetries: number;
89
+ private readonly requestTimeout: number;
70
90
 
71
91
  constructor() {
72
92
  const settings = getSettings();
73
93
 
74
94
  this.workspace = settings.bitbucketWorkspace;
75
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';
76
101
 
77
102
  this.client = axios.create({
78
103
  baseURL: BitbucketClient.BASE_URL,
79
- timeout: settings.apiTimeout * 1000,
80
- auth: {
81
- username: settings.bitbucketEmail,
82
- password: settings.bitbucketApiToken,
83
- },
104
+ timeout: this.requestTimeout,
105
+ paramsSerializer: { indexes: null },
106
+ ...(useBearer
107
+ ? {}
108
+ : {
109
+ auth: {
110
+ username: settings.bitbucketEmail,
111
+ password: settings.bitbucketApiToken,
112
+ },
113
+ }),
84
114
  headers: {
85
115
  'Content-Type': 'application/json',
116
+ ...(useBearer ? { Authorization: `Bearer ${settings.bitbucketApiToken}` } : {}),
86
117
  },
87
118
  });
88
119
  }
@@ -95,126 +126,137 @@ export class BitbucketClient {
95
126
  return parts.length > 0 ? `${base}/${parts.join('/')}` : base;
96
127
  }
97
128
 
98
- /**
99
- * Make an API request with retry logic for rate limiting
100
- */
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. */
101
142
  private async request<T>(
102
143
  method: 'GET' | 'POST' | 'PUT' | 'DELETE',
103
144
  path: string,
104
145
  data?: unknown,
105
- params?: Record<string, unknown>
146
+ params?: Record<string, unknown>,
147
+ text = false
106
148
  ): Promise<T | null> {
107
149
  let backoff = BitbucketClient.INITIAL_BACKOFF;
108
-
150
+ let slept = 0;
151
+ const deadline = performance.now() + this.requestTimeout;
109
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);
110
155
  try {
111
156
  const response = await this.client.request<T>({
112
- method,
113
- url: path,
114
- data,
115
- params,
157
+ method, url: path, data, params, timeout: Math.max(1, Math.floor(remaining)),
158
+ ...(text ? { responseType: 'text', transformResponse: [(value: unknown) => value] } : {}),
116
159
  });
117
-
118
160
  return response.data;
119
161
  } catch (error) {
120
- if (axios.isAxiosError(error)) {
121
- const axiosError = error as AxiosError;
122
-
123
- // Handle 404 as null (not found)
124
- if (axiosError.response?.status === 404) {
125
- 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;
126
180
  }
127
-
128
- // Handle rate limiting (429)
129
- if (axiosError.response?.status === 429) {
130
- if (attempt < this.maxRetries) {
131
- const retryAfter = axiosError.response.headers['retry-after'];
132
- const waitTime = retryAfter ? parseInt(retryAfter, 10) * 1000 : backoff;
133
- await sleep(waitTime);
134
- backoff *= 2;
135
- continue;
136
- }
137
- throw new BitbucketError(
138
- `Rate limited after ${this.maxRetries} retries`,
139
- 429,
140
- method,
141
- path
142
- );
181
+ if (wait > 15000 - slept || wait >= deadline - performance.now()) {
182
+ throw new BitbucketError('Retry-After exceeds request retry budget; retry later', status, method, path);
143
183
  }
144
-
145
- // Other errors
146
- const statusCode = axiosError.response?.status;
147
- const errorText = JSON.stringify(axiosError.response?.data || axiosError.message).substring(0, 500);
148
- throw new BitbucketError(
149
- `API error ${statusCode}: ${errorText}`,
150
- statusCode,
151
- method,
152
- path
153
- );
184
+ await sleep(wait);
185
+ slept += wait;
186
+ backoff *= 2;
187
+ continue;
154
188
  }
155
- 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
+ );
156
194
  }
157
195
  }
196
+ throw new BitbucketError('Request retry budget exhausted', undefined, method, path);
197
+ }
158
198
 
159
- 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);
160
201
  }
161
202
 
162
- /**
163
- * Make a request that returns plain text
164
- */
165
- private async requestText(path: string): Promise<string | null> {
166
- 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
+ }
167
223
 
168
- for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
169
- try {
170
- const response = await this.client.get(path, {
171
- responseType: 'text',
172
- });
173
- return response.data;
174
- } catch (error) {
175
- if (axios.isAxiosError(error)) {
176
- if (error.response?.status === 404) {
177
- return null;
178
- }
179
- if (error.response?.status === 429) {
180
- if (attempt < this.maxRetries) {
181
- const retryAfter = error.response.headers['retry-after'];
182
- const waitTime = retryAfter ? parseInt(retryAfter, 10) * 1000 : backoff;
183
- await sleep(waitTime);
184
- backoff *= 2;
185
- continue;
186
- }
187
- }
188
- throw new BitbucketError(`Request failed: ${error.response?.status}`);
189
- }
190
- throw error;
191
- }
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);
192
236
  }
193
- 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 };
194
240
  }
195
241
 
196
- /**
197
- * Helper for paginated list endpoints
198
- */
199
242
  private async paginatedList<T>(
200
243
  endpoint: string,
201
244
  options: { limit?: number; maxPage?: number } & Record<string, unknown> = {}
202
245
  ): Promise<T[]> {
203
- const { limit = 50, maxPage = 100, ...extraParams } = options;
204
- const params: Record<string, unknown> = {
205
- pagelen: Math.min(limit, maxPage),
206
- ...extraParams,
207
- };
208
-
209
- // Filter out undefined values
210
- Object.keys(params).forEach(key => {
211
- if (params[key] === undefined) {
212
- delete params[key];
213
- }
214
- });
215
-
216
- const result = await this.request<PaginatedResponse<T>>('GET', endpoint, undefined, params);
217
- 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');
218
260
  }
219
261
 
220
262
  // ==================== REPOSITORIES ====================
@@ -628,6 +670,43 @@ export class BitbucketClient {
628
670
  return result || { uuid: pipelineUuid, state: { name: 'STOPPED' } };
629
671
  }
630
672
 
673
+ // ==================== PIPELINE CONFIG ====================
674
+
675
+ async getPipelineConfig(repoSlug: string): Promise<BitbucketPipelineConfig> {
676
+ const result = await this.request<BitbucketPipelineConfig>(
677
+ 'GET',
678
+ this.repoPath(repoSlug, 'pipelines_config')
679
+ );
680
+ if (!result) {
681
+ throw new BitbucketError(`Failed to get pipeline config for: ${repoSlug}`);
682
+ }
683
+ return result;
684
+ }
685
+
686
+ async updatePipelineConfig(
687
+ repoSlug: string,
688
+ options: { enabled?: boolean }
689
+ ): Promise<BitbucketPipelineConfig> {
690
+ const payload: Record<string, unknown> = {};
691
+ if (options.enabled !== undefined) {
692
+ payload.enabled = options.enabled;
693
+ }
694
+
695
+ if (Object.keys(payload).length === 0) {
696
+ throw new BitbucketError('No fields to update');
697
+ }
698
+
699
+ const result = await this.request<BitbucketPipelineConfig>(
700
+ 'PUT',
701
+ this.repoPath(repoSlug, 'pipelines_config'),
702
+ payload
703
+ );
704
+ if (!result) {
705
+ throw new BitbucketError(`Failed to update pipeline config for: ${repoSlug}`);
706
+ }
707
+ return result;
708
+ }
709
+
631
710
  // ==================== PIPELINE VARIABLES ====================
632
711
 
633
712
  async listPipelineVariables(
@@ -702,32 +781,74 @@ export class BitbucketClient {
702
781
  }
703
782
 
704
783
  async getBranch(repoSlug: string, branchName: string): Promise<BitbucketBranch | null> {
705
- return this.request('GET', this.repoPath(repoSlug, 'refs', 'branches', branchName));
784
+ return this.request('GET', this.repoPath(repoSlug, 'refs', 'branches', this.encodeRef(branchName)));
706
785
  }
707
786
 
708
787
  // ==================== COMMITS ====================
709
788
 
710
- async listCommits(
711
- repoSlug: string,
712
- options: { branch?: string; path?: string; limit?: number } = {}
713
- ): Promise<BitbucketCommit[]> {
789
+ async listCommits(repoSlug: string, options: CommitHistoryOptions = {}): Promise<BitbucketCommit[]> {
714
790
  return this.paginatedList(this.repoPath(repoSlug, 'commits'), {
715
- limit: options.limit || 20,
716
- include: options.branch,
717
- 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,
718
802
  });
719
803
  }
720
804
 
721
805
  async getCommit(repoSlug: string, commit: string): Promise<BitbucketCommit | null> {
722
- return this.request('GET', this.repoPath(repoSlug, 'commit', commit));
806
+ return this.request('GET', this.repoPath(repoSlug, 'commit', this.encodeRef(commit)));
723
807
  }
724
808
 
725
- async compareCommits(
726
- repoSlug: string,
727
- base: string,
728
- head: string
729
- ): Promise<Record<string, unknown> | null> {
730
- 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
+ });
731
852
  }
732
853
 
733
854
  async getCommitStatuses(
@@ -865,13 +986,22 @@ export class BitbucketClient {
865
986
 
866
987
  // ==================== TAGS ====================
867
988
 
868
- async listTags(repoSlug: string, options: { limit?: number } = {}): Promise<BitbucketTag[]> {
989
+ async listTags(repoSlug: string, options: { limit?: number; query?: string; sort?: string } = {}): Promise<BitbucketTag[]> {
869
990
  return this.paginatedList(this.repoPath(repoSlug, 'refs', 'tags'), {
870
- limit: options.limit || 50,
871
- sort: '-target.date',
991
+ limit: options.limit ?? 50, sort: options.sort ?? '-target.date', q: options.query,
992
+ });
993
+ }
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,
872
998
  });
873
999
  }
874
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
+
875
1005
  async createTag(
876
1006
  repoSlug: string,
877
1007
  name: string,
@@ -898,7 +1028,7 @@ export class BitbucketClient {
898
1028
  }
899
1029
 
900
1030
  async deleteTag(repoSlug: string, tagName: string): Promise<void> {
901
- await this.request('DELETE', this.repoPath(repoSlug, 'refs', 'tags', tagName));
1031
+ await this.request('DELETE', this.repoPath(repoSlug, 'refs', 'tags', this.encodeRef(tagName)));
902
1032
  }
903
1033
 
904
1034
  // ==================== BRANCH RESTRICTIONS ====================
@@ -961,7 +1091,7 @@ export class BitbucketClient {
961
1091
  path: string,
962
1092
  ref: string = 'main'
963
1093
  ): Promise<string | null> {
964
- return this.requestText(this.repoPath(repoSlug, 'src', ref, path));
1094
+ return this.requestText(this.repoPath(repoSlug, 'src', this.encodeRef(ref), this.encodeFilePath(path)));
965
1095
  }
966
1096
 
967
1097
  async listDirectory(
@@ -970,8 +1100,8 @@ export class BitbucketClient {
970
1100
  options: { ref?: string; limit?: number } = {}
971
1101
  ): Promise<DirectoryEntry[]> {
972
1102
  const endpoint = path
973
- ? this.repoPath(repoSlug, 'src', options.ref || 'main', path)
974
- : 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'));
975
1105
  return this.paginatedList(endpoint, { limit: options.limit || 100 });
976
1106
  }
977
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
+ }