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/dist/index.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // src/index.ts
4
4
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
5
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
6
7
  import {
7
8
  CallToolRequestSchema,
8
9
  ListToolsRequestSchema,
@@ -11,26 +12,374 @@ import {
11
12
  ListPromptsRequestSchema,
12
13
  GetPromptRequestSchema
13
14
  } from "@modelcontextprotocol/sdk/types.js";
15
+ import { createServer as createHttpServer } from "http";
16
+ import { pathToFileURL } from "url";
17
+ import { realpathSync } from "fs";
18
+
19
+ // src/http-input.ts
20
+ var HttpInputError = class extends Error {
21
+ constructor(message, status = 400) {
22
+ super(message);
23
+ this.status = status;
24
+ }
25
+ };
26
+ async function readHttpBody(source) {
27
+ const chunks = [];
28
+ let bytes = 0;
29
+ for await (const chunk of source) {
30
+ bytes += chunk.byteLength;
31
+ if (bytes > 100 * 1024) throw new HttpInputError("Request body exceeds 100 KiB", 413);
32
+ chunks.push(Buffer.from(chunk));
33
+ }
34
+ return Buffer.concat(chunks);
35
+ }
36
+ function parseMcpBody(body) {
37
+ const parsed = JSON.parse(body.toString("utf8"));
38
+ if (Array.isArray(parsed)) throw new HttpInputError("JSON-RPC batches are not supported");
39
+ if (parsed && typeof parsed === "object" && "method" in parsed && parsed.method === "tools/call") {
40
+ const pending = [parsed];
41
+ let elements = 0;
42
+ while (pending.length) {
43
+ const value = pending.pop();
44
+ if (value && typeof value === "object") {
45
+ const values = Object.values(value);
46
+ elements += values.length;
47
+ if (elements > 1e3) throw new HttpInputError("Tool input exceeds 1000 elements");
48
+ pending.push(...values);
49
+ }
50
+ }
51
+ }
52
+ return parsed;
53
+ }
54
+
55
+ // src/auth.ts
56
+ import { createRemoteJWKSet, jwtVerify } from "jose";
57
+ function defaultJwksUri(issuer) {
58
+ const base = issuer.replace(/\/+$/, "").replace(/\/v2\.0$/, "");
59
+ return `${base}/discovery/v2.0/keys`;
60
+ }
61
+ function getAuthConfig(env = process.env, jwksOverride) {
62
+ const issuer = (env.MCP_OAUTH_ISSUER || "").trim();
63
+ const audience = (env.MCP_OAUTH_AUDIENCE || "").trim();
64
+ if (!issuer || !audience) {
65
+ return null;
66
+ }
67
+ const jwksUri = (env.MCP_OAUTH_JWKS_URI || "").trim() || defaultJwksUri(issuer);
68
+ const requiredScopes = parseScopes(env.MCP_OAUTH_REQUIRED_SCOPE);
69
+ const advertisedScopes = parseScopes(env.MCP_OAUTH_SCOPES_SUPPORTED) ?? requiredScopes;
70
+ const publicUrl = (env.MCP_PUBLIC_URL || "").trim().replace(/\/+$/, "");
71
+ const resourceUrl = publicUrl ? `${publicUrl}/mcp` : audience;
72
+ const resourceIdentifier = (env.MCP_OAUTH_RESOURCE || "").trim() || resourceUrl;
73
+ return {
74
+ issuer,
75
+ audience,
76
+ requiredScopes,
77
+ advertisedScopes,
78
+ resourceUrl,
79
+ resourceIdentifier,
80
+ proxyAuthorizationServerMetadata: (env.MCP_OAUTH_AS_METADATA || "proxy").trim() !== "issuer",
81
+ jwks: jwksOverride ?? createRemoteJWKSet(new URL(jwksUri))
82
+ };
83
+ }
84
+ function parseScopes(raw) {
85
+ const scopes = (raw || "").split(/[\s,]+/).filter(Boolean);
86
+ return scopes.length > 0 ? scopes : void 0;
87
+ }
88
+ function protectedResourceMetadata(config) {
89
+ const scopes = config.advertisedScopes ?? config.requiredScopes;
90
+ const authorizationServer = config.proxyAuthorizationServerMetadata ? serverOrigin(config) : config.issuer;
91
+ return {
92
+ resource: config.resourceIdentifier,
93
+ authorization_servers: [authorizationServer],
94
+ bearer_methods_supported: ["header"],
95
+ ...scopes ? { scopes_supported: scopes } : {}
96
+ };
97
+ }
98
+ var METADATA_PATHS = [
99
+ "/.well-known/oauth-protected-resource/mcp",
100
+ "/.well-known/oauth-protected-resource"
101
+ ];
102
+ function serverOrigin(config) {
103
+ return config.resourceUrl.replace(/\/mcp$/, "");
104
+ }
105
+ function metadataUrl(config) {
106
+ return `${serverOrigin(config)}${METADATA_PATHS[0]}`;
107
+ }
108
+ var AS_METADATA_PATH = "/.well-known/oauth-authorization-server";
109
+ var asMetadataCache = null;
110
+ var AS_METADATA_TTL_MS = 60 * 60 * 1e3;
111
+ async function authorizationServerMetadata(config, fetchImpl = fetch, now = Date.now()) {
112
+ if (asMetadataCache && now - asMetadataCache.fetchedAt < AS_METADATA_TTL_MS) {
113
+ return asMetadataCache.document;
114
+ }
115
+ const url = `${config.issuer.replace(/\/+$/, "")}/.well-known/openid-configuration`;
116
+ const response = await fetchImpl(url);
117
+ if (!response.ok) {
118
+ throw new Error(`Could not read OpenID configuration from ${url}: HTTP ${response.status}`);
119
+ }
120
+ const upstream = await response.json();
121
+ const document = {
122
+ ...upstream,
123
+ code_challenge_methods_supported: upstream.code_challenge_methods_supported ?? ["S256"],
124
+ grant_types_supported: upstream.grant_types_supported ?? ["authorization_code", "refresh_token"]
125
+ };
126
+ asMetadataCache = { document, fetchedAt: now };
127
+ return document;
128
+ }
129
+ function wwwAuthenticate(config, failure) {
130
+ const parts = [
131
+ `Bearer realm="mcp"`,
132
+ `resource_metadata="${metadataUrl(config)}"`
133
+ ];
134
+ if (failure) {
135
+ parts.push(`error="${failure.error}"`, `error_description="${failure.description}"`);
136
+ }
137
+ return parts.join(", ");
138
+ }
139
+ function tokenScopes(payload) {
140
+ const scp = payload.scp;
141
+ if (typeof scp === "string") {
142
+ return scp.split(" ").filter(Boolean);
143
+ }
144
+ if (Array.isArray(scp)) {
145
+ return scp.filter((s) => typeof s === "string");
146
+ }
147
+ const roles = payload.roles;
148
+ if (Array.isArray(roles)) {
149
+ return roles.filter((r) => typeof r === "string");
150
+ }
151
+ return [];
152
+ }
153
+ async function verifyBearer(authorization, config) {
154
+ if (!authorization) {
155
+ return {
156
+ ok: false,
157
+ status: 401,
158
+ error: "invalid_token",
159
+ description: "Authorization header is required"
160
+ };
161
+ }
162
+ const match = /^Bearer[ ]+(.+)$/i.exec(authorization.trim());
163
+ if (!match) {
164
+ return {
165
+ ok: false,
166
+ status: 401,
167
+ error: "invalid_token",
168
+ description: "Authorization header must use the Bearer scheme"
169
+ };
170
+ }
171
+ let payload;
172
+ try {
173
+ const verified = await jwtVerify(match[1], config.jwks, {
174
+ issuer: config.issuer,
175
+ audience: config.audience
176
+ });
177
+ payload = verified.payload;
178
+ } catch (error) {
179
+ return {
180
+ ok: false,
181
+ status: 401,
182
+ error: "invalid_token",
183
+ description: error instanceof Error ? error.message : "Token verification failed"
184
+ };
185
+ }
186
+ if (config.requiredScopes) {
187
+ const granted = tokenScopes(payload);
188
+ if (!config.requiredScopes.some((scope) => granted.includes(scope))) {
189
+ return {
190
+ ok: false,
191
+ status: 403,
192
+ error: "insufficient_scope",
193
+ description: `Token carries none of the required scopes: ${config.requiredScopes.join(", ")}`
194
+ };
195
+ }
196
+ }
197
+ const caller = [payload.preferred_username, payload.upn, payload.appid].find((value) => typeof value === "string" && value.length > 0);
198
+ return { ok: true, subject: typeof payload.sub === "string" ? payload.sub : void 0, caller };
199
+ }
200
+
201
+ // src/oauth-proxy.ts
202
+ var AUTHORIZE_PATH = "/oauth/authorize";
203
+ var TOKEN_PATH = "/oauth/token";
204
+ var REGISTER_PATH = "/oauth/register";
205
+ var DEFAULT_REDIRECT_ALLOWLIST = [
206
+ "https://claude.ai/api/mcp/auth_callback",
207
+ "https://claude.com/api/mcp/auth_callback"
208
+ ];
209
+ function isAllowedRedirect(uri, allowlist) {
210
+ if (allowlist.includes(uri)) {
211
+ return true;
212
+ }
213
+ try {
214
+ const parsed = new URL(uri);
215
+ return parsed.protocol === "http:" && (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "[::1]");
216
+ } catch {
217
+ return false;
218
+ }
219
+ }
220
+ function getProxyConfig(auth, env = process.env) {
221
+ const configured = (env.MCP_OAUTH_REDIRECT_ALLOWLIST || "").split(/[\s,]+/).filter(Boolean);
222
+ return {
223
+ origin: serverOrigin(auth),
224
+ clientId: (env.MCP_OAUTH_CLIENT_ID || "").trim(),
225
+ upstreamScope: (env.MCP_OAUTH_UPSTREAM_SCOPE || "").trim() || [...auth.advertisedScopes ?? [], "offline_access"].join(" ").trim(),
226
+ redirectAllowlist: configured.length > 0 ? configured : DEFAULT_REDIRECT_ALLOWLIST
227
+ };
228
+ }
229
+ function proxyAuthorizationServerMetadata(auth, proxy) {
230
+ return {
231
+ issuer: proxy.origin,
232
+ authorization_endpoint: `${proxy.origin}${AUTHORIZE_PATH}`,
233
+ token_endpoint: `${proxy.origin}${TOKEN_PATH}`,
234
+ response_types_supported: ["code"],
235
+ grant_types_supported: ["authorization_code", "refresh_token"],
236
+ code_challenge_methods_supported: ["S256"],
237
+ token_endpoint_auth_methods_supported: ["none"],
238
+ ...proxy.clientId ? { registration_endpoint: `${proxy.origin}${REGISTER_PATH}` } : {},
239
+ ...auth.advertisedScopes ? { scopes_supported: auth.advertisedScopes } : {}
240
+ };
241
+ }
242
+ function buildAuthorizeRedirect(requestUrl, proxy, upstreamAuthorizeEndpoint) {
243
+ const redirectUri = requestUrl.searchParams.get("redirect_uri");
244
+ if (!redirectUri) {
245
+ return { status: 400, error: "invalid_request", description: "redirect_uri is required" };
246
+ }
247
+ if (!isAllowedRedirect(redirectUri, proxy.redirectAllowlist)) {
248
+ return {
249
+ status: 400,
250
+ error: "invalid_request",
251
+ description: "redirect_uri is not allowed for this server"
252
+ };
253
+ }
254
+ const upstream = new URL(upstreamAuthorizeEndpoint);
255
+ const passthrough = [
256
+ "client_id",
257
+ "response_type",
258
+ "redirect_uri",
259
+ "state",
260
+ "code_challenge",
261
+ "code_challenge_method",
262
+ "prompt",
263
+ "login_hint"
264
+ ];
265
+ for (const name of passthrough) {
266
+ const value = requestUrl.searchParams.get(name);
267
+ if (value !== null) {
268
+ upstream.searchParams.set(name, value);
269
+ }
270
+ }
271
+ upstream.searchParams.set("scope", proxy.upstreamScope);
272
+ return { status: 302, location: upstream.toString() };
273
+ }
274
+ function buildTokenRequestBody(rawBody) {
275
+ const params = new URLSearchParams(rawBody);
276
+ params.delete("resource");
277
+ return params;
278
+ }
279
+ async function forwardTokenRequest(rawBody, authorizationHeader, upstreamTokenEndpoint, fetchImpl = fetch) {
280
+ const response = await fetchImpl(upstreamTokenEndpoint, {
281
+ method: "POST",
282
+ headers: {
283
+ "Content-Type": "application/x-www-form-urlencoded",
284
+ ...authorizationHeader ? { Authorization: authorizationHeader } : {}
285
+ },
286
+ body: buildTokenRequestBody(rawBody).toString()
287
+ });
288
+ return {
289
+ status: response.status,
290
+ body: await response.text(),
291
+ contentType: response.headers.get("content-type") ?? "application/json"
292
+ };
293
+ }
294
+ async function resolveUpstreamEndpoints(auth, fetchImpl = fetch) {
295
+ const metadata = await authorizationServerMetadata(auth, fetchImpl);
296
+ const authorize = metadata.authorization_endpoint;
297
+ const token = metadata.token_endpoint;
298
+ if (typeof authorize !== "string" || typeof token !== "string") {
299
+ throw new Error("Issuer configuration is missing authorization_endpoint or token_endpoint");
300
+ }
301
+ return { authorize, token };
302
+ }
303
+ function redactUrlForLog(url) {
304
+ const [path, query] = url.split("?");
305
+ return query ? `${path}?<redacted>` : path;
306
+ }
307
+ function registerClient(request, proxy) {
308
+ if (!proxy.clientId) {
309
+ return {
310
+ status: 400,
311
+ error: "invalid_request",
312
+ description: "This server has no upstream client configured"
313
+ };
314
+ }
315
+ const body = request ?? {};
316
+ const redirectUris = Array.isArray(body.redirect_uris) ? body.redirect_uris : [];
317
+ if (redirectUris.length === 0) {
318
+ return { status: 400, error: "invalid_redirect_uri", description: "redirect_uris is required" };
319
+ }
320
+ const rejected = redirectUris.filter(
321
+ (uri) => typeof uri !== "string" || !isAllowedRedirect(uri, proxy.redirectAllowlist)
322
+ );
323
+ if (rejected.length > 0) {
324
+ return {
325
+ status: 400,
326
+ error: "invalid_redirect_uri",
327
+ description: "redirect_uris are not allowed for this server"
328
+ };
329
+ }
330
+ return {
331
+ status: 201,
332
+ body: {
333
+ client_id: proxy.clientId,
334
+ redirect_uris: redirectUris,
335
+ token_endpoint_auth_method: "none",
336
+ grant_types: ["authorization_code", "refresh_token"],
337
+ response_types: ["code"],
338
+ client_id_issued_at: Math.floor(Date.now() / 1e3),
339
+ ...typeof body.client_name === "string" ? { client_name: body.client_name } : {}
340
+ }
341
+ };
342
+ }
14
343
 
15
344
  // src/settings.ts
16
345
  import { z } from "zod";
17
346
  var settingsSchema = z.object({
18
347
  bitbucketWorkspace: z.string().min(1, "BITBUCKET_WORKSPACE is required"),
19
- bitbucketEmail: z.string().min(1, "BITBUCKET_EMAIL is required"),
20
- bitbucketApiToken: z.string().min(1, "BITBUCKET_API_TOKEN is required"),
348
+ bitbucketEmail: z.string().default(""),
349
+ bitbucketApiToken: z.string().min(1, "BITBUCKET_API_TOKEN (or BITBUCKET_OAUTH_TOKEN) is required"),
350
+ bitbucketAuthType: z.enum(["basic", "bearer"]),
21
351
  apiTimeout: z.number().min(1).max(300).default(30),
22
352
  maxRetries: z.number().min(0).max(10).default(3),
23
353
  outputFormat: z.enum(["json", "toon"]).default("json")
24
- });
354
+ }).refine(
355
+ (s) => s.bitbucketAuthType !== "basic" || s.bitbucketEmail.length > 0,
356
+ { message: "BITBUCKET_EMAIL is required when using basic auth", path: ["bitbucketEmail"] }
357
+ );
25
358
  var cachedSettings = null;
359
+ function resolveAuthType(explicit, oauthToken, email) {
360
+ const normalized = explicit.trim().toLowerCase();
361
+ if (normalized === "bearer" || normalized === "basic") {
362
+ return normalized;
363
+ }
364
+ if (oauthToken.length > 0 || email.length === 0) {
365
+ return "bearer";
366
+ }
367
+ return "basic";
368
+ }
26
369
  function getSettings() {
27
370
  if (cachedSettings) {
28
371
  return cachedSettings;
29
372
  }
373
+ const email = process.env.BITBUCKET_EMAIL || "";
374
+ const oauthToken = process.env.BITBUCKET_OAUTH_TOKEN || "";
375
+ const apiToken = process.env.BITBUCKET_API_TOKEN || "";
376
+ const authType = resolveAuthType(process.env.BITBUCKET_AUTH_TYPE || "", oauthToken, email);
30
377
  const rawSettings = {
31
378
  bitbucketWorkspace: process.env.BITBUCKET_WORKSPACE || "",
32
- bitbucketEmail: process.env.BITBUCKET_EMAIL || "",
33
- bitbucketApiToken: process.env.BITBUCKET_API_TOKEN || "",
379
+ bitbucketEmail: email,
380
+ // Bearer mode prefers the dedicated OAuth/access token when both are set
381
+ bitbucketApiToken: authType === "bearer" ? oauthToken || apiToken : apiToken,
382
+ bitbucketAuthType: authType,
34
383
  apiTimeout: parseInt(process.env.API_TIMEOUT || "30", 10),
35
384
  maxRetries: parseInt(process.env.MAX_RETRIES || "3", 10),
36
385
  outputFormat: process.env.OUTPUT_FORMAT || "json"
@@ -94,19 +443,26 @@ var BitbucketClient = class _BitbucketClient {
94
443
  workspace;
95
444
  client;
96
445
  maxRetries;
446
+ requestTimeout;
97
447
  constructor() {
98
448
  const settings = getSettings();
99
449
  this.workspace = settings.bitbucketWorkspace;
100
450
  this.maxRetries = settings.maxRetries;
451
+ this.requestTimeout = settings.apiTimeout * 1e3;
452
+ const useBearer = settings.bitbucketAuthType === "bearer";
101
453
  this.client = axios.create({
102
454
  baseURL: _BitbucketClient.BASE_URL,
103
- timeout: settings.apiTimeout * 1e3,
104
- auth: {
105
- username: settings.bitbucketEmail,
106
- password: settings.bitbucketApiToken
455
+ timeout: this.requestTimeout,
456
+ paramsSerializer: { indexes: null },
457
+ ...useBearer ? {} : {
458
+ auth: {
459
+ username: settings.bitbucketEmail,
460
+ password: settings.bitbucketApiToken
461
+ }
107
462
  },
108
463
  headers: {
109
- "Content-Type": "application/json"
464
+ "Content-Type": "application/json",
465
+ ...useBearer ? { Authorization: `Bearer ${settings.bitbucketApiToken}` } : {}
110
466
  }
111
467
  });
112
468
  }
@@ -117,103 +473,126 @@ var BitbucketClient = class _BitbucketClient {
117
473
  const base = `repositories/${this.workspace}/${repoSlug}`;
118
474
  return parts.length > 0 ? `${base}/${parts.join("/")}` : base;
119
475
  }
120
- /**
121
- * Make an API request with retry logic for rate limiting
122
- */
123
- async request(method, path, data, params) {
476
+ encodeRef(ref) {
477
+ if (!ref || ref.split("/").some((part) => part === "." || part === "..")) throw new BitbucketError("Invalid revision name");
478
+ return encodeURIComponent(ref);
479
+ }
480
+ encodeFilePath(path) {
481
+ if (path.startsWith("/") || path.split("/").some((part) => part === "." || part === "..")) {
482
+ throw new BitbucketError("Invalid repository file path");
483
+ }
484
+ return path.split("/").map(encodeURIComponent).join("/");
485
+ }
486
+ /** Retry only reads after ambiguous network/server failures; writes may retry 429. */
487
+ async request(method, path, data, params, text = false) {
124
488
  let backoff = _BitbucketClient.INITIAL_BACKOFF;
489
+ let slept = 0;
490
+ const deadline = performance.now() + this.requestTimeout;
125
491
  for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
492
+ const remaining = deadline - performance.now();
493
+ if (remaining <= 0) throw new BitbucketError("Request retry budget exhausted", void 0, method, path);
126
494
  try {
127
495
  const response = await this.client.request({
128
496
  method,
129
497
  url: path,
130
498
  data,
131
- params
499
+ params,
500
+ timeout: Math.max(1, Math.floor(remaining)),
501
+ ...text ? { responseType: "text", transformResponse: [(value) => value] } : {}
132
502
  });
133
503
  return response.data;
134
504
  } catch (error) {
135
- if (axios.isAxiosError(error)) {
136
- const axiosError = error;
137
- if (axiosError.response?.status === 404) {
138
- return null;
505
+ if (!axios.isAxiosError(error)) throw error;
506
+ const status = error.response?.status;
507
+ if (status === 404) return null;
508
+ const abortedBody = error.code === "ERR_BAD_RESPONSE" && error.message === "stream has been aborted";
509
+ const network = abortedBody || !error.response && ["ECONNRESET", "ECONNREFUSED", "ECONNABORTED", "ETIMEDOUT", "EPIPE", "EAI_AGAIN", "ENOTFOUND", "ERR_NETWORK"].includes(error.code || "");
510
+ const retryable = status === 429 || method === "GET" && (network || [502, 503, 504].includes(status ?? 0));
511
+ if (retryable && attempt < this.maxRetries) {
512
+ const header = error.response?.headers["retry-after"];
513
+ let wait = backoff;
514
+ if (typeof header === "string" && header.trim()) {
515
+ const numeric = Number(header);
516
+ const dated = /[a-z]/i.test(header) && !["nan", "inf", "infinity"].includes(header.toLowerCase()) ? Date.parse(header) - Date.now() : NaN;
517
+ const parsed = Number.isFinite(numeric) && numeric >= 0 ? numeric * 1e3 : dated;
518
+ if (Number.isFinite(parsed) && parsed >= 0) wait = parsed;
139
519
  }
140
- if (axiosError.response?.status === 429) {
141
- if (attempt < this.maxRetries) {
142
- const retryAfter = axiosError.response.headers["retry-after"];
143
- const waitTime = retryAfter ? parseInt(retryAfter, 10) * 1e3 : backoff;
144
- await sleep(waitTime);
145
- backoff *= 2;
146
- continue;
147
- }
148
- throw new BitbucketError(
149
- `Rate limited after ${this.maxRetries} retries`,
150
- 429,
151
- method,
152
- path
153
- );
520
+ if (wait > 15e3 - slept || wait >= deadline - performance.now()) {
521
+ throw new BitbucketError("Retry-After exceeds request retry budget; retry later", status, method, path);
154
522
  }
155
- const statusCode = axiosError.response?.status;
156
- const errorText = JSON.stringify(axiosError.response?.data || axiosError.message).substring(0, 500);
157
- throw new BitbucketError(
158
- `API error ${statusCode}: ${errorText}`,
159
- statusCode,
160
- method,
161
- path
162
- );
523
+ await sleep(wait);
524
+ slept += wait;
525
+ backoff *= 2;
526
+ continue;
163
527
  }
164
- throw error;
528
+ throw new BitbucketError(
529
+ network ? `Network request failed after ${attempt + 1} attempt(s)` : `API error ${status ?? "unknown"}`,
530
+ status,
531
+ method,
532
+ path
533
+ );
165
534
  }
166
535
  }
167
- throw new BitbucketError(`Unexpected error in request`, void 0, method, path);
536
+ throw new BitbucketError("Request retry budget exhausted", void 0, method, path);
168
537
  }
169
- /**
170
- * Make a request that returns plain text
171
- */
172
- async requestText(path) {
173
- let backoff = _BitbucketClient.INITIAL_BACKOFF;
174
- for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
538
+ requestText(path, params) {
539
+ return this.request("GET", path, void 0, params, true);
540
+ }
541
+ /** A next link can change only query parameters, never the authenticated endpoint. */
542
+ validateCursor(cursor, endpoint) {
543
+ const expected = new URL(endpoint, _BitbucketClient.BASE_URL + "/");
544
+ let parsed;
545
+ try {
546
+ parsed = new URL(cursor);
547
+ } catch {
548
+ throw new BitbucketError("Invalid pagination cursor");
549
+ }
550
+ const rawPath = cursor.split(/[?#]/, 1)[0].replace(/^https:\/\/[^/]+/, "");
551
+ const segments = rawPath.split("/");
552
+ const unsafeSegments = segments.some((segment) => {
553
+ let decoded;
175
554
  try {
176
- const response = await this.client.get(path, {
177
- responseType: "text"
178
- });
179
- return response.data;
180
- } catch (error) {
181
- if (axios.isAxiosError(error)) {
182
- if (error.response?.status === 404) {
183
- return null;
184
- }
185
- if (error.response?.status === 429) {
186
- if (attempt < this.maxRetries) {
187
- const retryAfter = error.response.headers["retry-after"];
188
- const waitTime = retryAfter ? parseInt(retryAfter, 10) * 1e3 : backoff;
189
- await sleep(waitTime);
190
- backoff *= 2;
191
- continue;
192
- }
193
- }
194
- throw new BitbucketError(`Request failed: ${error.response?.status}`);
195
- }
196
- throw error;
555
+ decoded = decodeURIComponent(segment);
556
+ } catch {
557
+ return true;
197
558
  }
559
+ return decoded === "." || decoded === ".." || decoded.includes("\\");
560
+ });
561
+ if (parsed.protocol !== "https:" || parsed.hostname !== "api.bitbucket.org" || parsed.port && parsed.port !== "443" || parsed.username || parsed.password || parsed.hash || cursor.includes("\\") || unsafeSegments || parsed.pathname.replace(/\/$/, "") !== expected.pathname.replace(/\/$/, "")) {
562
+ throw new BitbucketError("Invalid pagination cursor for this endpoint");
198
563
  }
199
- return null;
564
+ return cursor;
565
+ }
566
+ async historyPage(endpoint, options = {}, allowMissing = false) {
567
+ const { limit = 50, cursor, maxPage = 100, ...filters } = options;
568
+ const params = { pagelen: Math.min(Math.max(1, Math.floor(limit)), maxPage), ...filters };
569
+ for (const key of Object.keys(params)) if (params[key] === void 0) delete params[key];
570
+ const result = await this.request(
571
+ "GET",
572
+ cursor ? this.validateCursor(cursor, endpoint) : endpoint,
573
+ void 0,
574
+ cursor ? void 0 : params
575
+ );
576
+ if (result === null && !allowMissing) {
577
+ throw new BitbucketError("History resource or revision not found (404)", 404, "GET", endpoint);
578
+ }
579
+ const next = result?.next ? this.validateCursor(result.next, endpoint) : null;
580
+ return { values: result?.values ?? [], has_more: next !== null, next_cursor: next };
200
581
  }
201
- /**
202
- * Helper for paginated list endpoints
203
- */
204
582
  async paginatedList(endpoint, options = {}) {
205
- const { limit = 50, maxPage = 100, ...extraParams } = options;
206
- const params = {
207
- pagelen: Math.min(limit, maxPage),
208
- ...extraParams
209
- };
210
- Object.keys(params).forEach((key) => {
211
- if (params[key] === void 0) {
212
- delete params[key];
213
- }
214
- });
215
- const result = await this.request("GET", endpoint, void 0, params);
216
- return result?.values || [];
583
+ const limit = Math.max(1, Math.floor(options.limit ?? 50));
584
+ const values = [];
585
+ const visited = /* @__PURE__ */ new Set();
586
+ let cursor;
587
+ for (let pageCount = 0; pageCount < 1e3; pageCount++) {
588
+ const page = await this.historyPage(endpoint, { ...options, cursor }, true);
589
+ values.push(...page.values);
590
+ if (values.length >= limit || !page.next_cursor) return values.slice(0, limit);
591
+ if (visited.has(page.next_cursor)) throw new BitbucketError("Repeated pagination cursor");
592
+ visited.add(page.next_cursor);
593
+ cursor = page.next_cursor;
594
+ }
595
+ throw new BitbucketError("Pagination page budget exhausted");
217
596
  }
218
597
  // ==================== REPOSITORIES ====================
219
598
  async getRepository(repoSlug) {
@@ -505,6 +884,35 @@ var BitbucketClient = class _BitbucketClient {
505
884
  const result = await this.getPipeline(repoSlug, pipelineUuid);
506
885
  return result || { uuid: pipelineUuid, state: { name: "STOPPED" } };
507
886
  }
887
+ // ==================== PIPELINE CONFIG ====================
888
+ async getPipelineConfig(repoSlug) {
889
+ const result = await this.request(
890
+ "GET",
891
+ this.repoPath(repoSlug, "pipelines_config")
892
+ );
893
+ if (!result) {
894
+ throw new BitbucketError(`Failed to get pipeline config for: ${repoSlug}`);
895
+ }
896
+ return result;
897
+ }
898
+ async updatePipelineConfig(repoSlug, options) {
899
+ const payload = {};
900
+ if (options.enabled !== void 0) {
901
+ payload.enabled = options.enabled;
902
+ }
903
+ if (Object.keys(payload).length === 0) {
904
+ throw new BitbucketError("No fields to update");
905
+ }
906
+ const result = await this.request(
907
+ "PUT",
908
+ this.repoPath(repoSlug, "pipelines_config"),
909
+ payload
910
+ );
911
+ if (!result) {
912
+ throw new BitbucketError(`Failed to update pipeline config for: ${repoSlug}`);
913
+ }
914
+ return result;
915
+ }
508
916
  // ==================== PIPELINE VARIABLES ====================
509
917
  async listPipelineVariables(repoSlug, options = {}) {
510
918
  return this.paginatedList(
@@ -553,21 +961,65 @@ var BitbucketClient = class _BitbucketClient {
553
961
  });
554
962
  }
555
963
  async getBranch(repoSlug, branchName) {
556
- return this.request("GET", this.repoPath(repoSlug, "refs", "branches", branchName));
964
+ return this.request("GET", this.repoPath(repoSlug, "refs", "branches", this.encodeRef(branchName)));
557
965
  }
558
966
  // ==================== COMMITS ====================
559
967
  async listCommits(repoSlug, options = {}) {
560
968
  return this.paginatedList(this.repoPath(repoSlug, "commits"), {
561
- limit: options.limit || 20,
562
- include: options.branch,
969
+ limit: options.limit ?? 20,
970
+ include: [...options.branch ? [options.branch] : [], ...options.include ?? []],
971
+ exclude: options.exclude,
972
+ path: options.path
973
+ });
974
+ }
975
+ listCommitsPage(repoSlug, options = {}) {
976
+ return this.historyPage(this.repoPath(repoSlug, "commits"), {
977
+ limit: options.limit ?? 20,
978
+ cursor: options.cursor,
979
+ include: [...options.branch ? [options.branch] : [], ...options.include ?? []],
980
+ exclude: options.exclude,
563
981
  path: options.path
564
982
  });
565
983
  }
566
984
  async getCommit(repoSlug, commit) {
567
- return this.request("GET", this.repoPath(repoSlug, "commit", commit));
985
+ return this.request("GET", this.repoPath(repoSlug, "commit", this.encodeRef(commit)));
986
+ }
987
+ compareCommits(repoSlug, base, head) {
988
+ return this.request("GET", this.repoPath(repoSlug, "diffstat", this.revisionRange(base, head)), void 0, { topic: false });
989
+ }
990
+ async resolveComparisonRef(repoSlug, ref) {
991
+ this.encodeRef(ref);
992
+ const commit = await this.getCommit(repoSlug, ref);
993
+ if (!commit) throw new BitbucketError("Commit or revision not found (404)", 404, "GET");
994
+ if (!/^[a-fA-F0-9]{12,40}$/.test(commit.hash)) throw new BitbucketError("Invalid commit hash returned by Bitbucket");
995
+ return commit.hash.slice(0, 12).toLowerCase();
996
+ }
997
+ async compareCommitsPage(repoSlug, base, head, options = {}) {
998
+ const canonicalBase = await this.resolveComparisonRef(repoSlug, base);
999
+ const canonicalHead = await this.resolveComparisonRef(repoSlug, head);
1000
+ return this.historyPage(this.repoPath(repoSlug, "diffstat", this.revisionRange(canonicalBase, canonicalHead)), {
1001
+ limit: options.limit ?? 50,
1002
+ cursor: options.cursor,
1003
+ path: options.path,
1004
+ topic: false
1005
+ });
568
1006
  }
569
- async compareCommits(repoSlug, base, head) {
570
- return this.request("GET", this.repoPath(repoSlug, "diffstat", `${base}..${head}`));
1007
+ /** Bitbucket takes the source first, the opposite of git diff base head. */
1008
+ revisionRange(base, head) {
1009
+ return `${this.encodeRef(head)}..${this.encodeRef(base)}`;
1010
+ }
1011
+ getCommitDiff(repoSlug, base, head, options = {}) {
1012
+ return this.requestText(this.repoPath(repoSlug, "diff", this.revisionRange(base, head)), { topic: false, path: options.path });
1013
+ }
1014
+ getMergeBase(repoSlug, base, head) {
1015
+ return this.request("GET", this.repoPath(repoSlug, "merge-base", this.revisionRange(base, head)));
1016
+ }
1017
+ getFileHistory(repoSlug, path, ref = "main", options = {}) {
1018
+ if (!path) throw new BitbucketError("File history requires a file path");
1019
+ return this.historyPage(this.repoPath(repoSlug, "filehistory", this.encodeRef(ref), this.encodeFilePath(path)), {
1020
+ limit: options.limit ?? 20,
1021
+ cursor: options.cursor
1022
+ });
571
1023
  }
572
1024
  async getCommitStatuses(repoSlug, commit, options = {}) {
573
1025
  return this.paginatedList(this.repoPath(repoSlug, "commit", commit, "statuses"), {
@@ -654,10 +1106,22 @@ var BitbucketClient = class _BitbucketClient {
654
1106
  // ==================== TAGS ====================
655
1107
  async listTags(repoSlug, options = {}) {
656
1108
  return this.paginatedList(this.repoPath(repoSlug, "refs", "tags"), {
657
- limit: options.limit || 50,
658
- sort: "-target.date"
1109
+ limit: options.limit ?? 50,
1110
+ sort: options.sort ?? "-target.date",
1111
+ q: options.query
1112
+ });
1113
+ }
1114
+ listTagsPage(repoSlug, options = {}) {
1115
+ return this.historyPage(this.repoPath(repoSlug, "refs", "tags"), {
1116
+ limit: options.limit ?? 50,
1117
+ cursor: options.cursor,
1118
+ sort: options.sort ?? "-target.date",
1119
+ q: options.query
659
1120
  });
660
1121
  }
1122
+ getTag(repoSlug, tagName) {
1123
+ return this.request("GET", this.repoPath(repoSlug, "refs", "tags", this.encodeRef(tagName)));
1124
+ }
661
1125
  async createTag(repoSlug, name, target, message) {
662
1126
  const payload = {
663
1127
  name,
@@ -677,7 +1141,7 @@ var BitbucketClient = class _BitbucketClient {
677
1141
  return result;
678
1142
  }
679
1143
  async deleteTag(repoSlug, tagName) {
680
- await this.request("DELETE", this.repoPath(repoSlug, "refs", "tags", tagName));
1144
+ await this.request("DELETE", this.repoPath(repoSlug, "refs", "tags", this.encodeRef(tagName)));
681
1145
  }
682
1146
  // ==================== BRANCH RESTRICTIONS ====================
683
1147
  async listBranchRestrictions(repoSlug, options = {}) {
@@ -717,10 +1181,10 @@ var BitbucketClient = class _BitbucketClient {
717
1181
  }
718
1182
  // ==================== SOURCE ====================
719
1183
  async getFileContent(repoSlug, path, ref = "main") {
720
- return this.requestText(this.repoPath(repoSlug, "src", ref, path));
1184
+ return this.requestText(this.repoPath(repoSlug, "src", this.encodeRef(ref), this.encodeFilePath(path)));
721
1185
  }
722
1186
  async listDirectory(repoSlug, path = "", options = {}) {
723
- const endpoint = path ? this.repoPath(repoSlug, "src", options.ref || "main", path) : this.repoPath(repoSlug, "src", options.ref || "main");
1187
+ const endpoint = path ? this.repoPath(repoSlug, "src", this.encodeRef(options.ref || "main"), this.encodeFilePath(path)) : this.repoPath(repoSlug, "src", this.encodeRef(options.ref || "main"));
724
1188
  return this.paginatedList(endpoint, { limit: options.limit || 100 });
725
1189
  }
726
1190
  // ==================== PERMISSIONS ====================
@@ -1459,6 +1923,29 @@ var definitions3 = [
1459
1923
  },
1460
1924
  required: ["repo_slug", "variable_uuid"]
1461
1925
  }
1926
+ },
1927
+ {
1928
+ name: "get_pipeline_config",
1929
+ description: "Get pipeline configuration for a repository (check if pipelines are enabled).",
1930
+ inputSchema: {
1931
+ type: "object",
1932
+ properties: {
1933
+ repo_slug: { type: "string", description: "Repository slug" }
1934
+ },
1935
+ required: ["repo_slug"]
1936
+ }
1937
+ },
1938
+ {
1939
+ name: "update_pipeline_config",
1940
+ description: "Update pipeline configuration for a repository (enable or disable pipelines).",
1941
+ inputSchema: {
1942
+ type: "object",
1943
+ properties: {
1944
+ repo_slug: { type: "string", description: "Repository slug" },
1945
+ enabled: { type: "boolean", description: "Enable or disable pipelines" }
1946
+ },
1947
+ required: ["repo_slug", "enabled"]
1948
+ }
1462
1949
  }
1463
1950
  ];
1464
1951
  var handlers3 = {
@@ -1598,6 +2085,22 @@ var handlers3 = {
1598
2085
  const client = getClient();
1599
2086
  await client.deletePipelineVariable(args.repo_slug, args.variable_uuid);
1600
2087
  return {};
2088
+ },
2089
+ get_pipeline_config: async (args) => {
2090
+ const client = getClient();
2091
+ const result = await client.getPipelineConfig(args.repo_slug);
2092
+ return {
2093
+ enabled: result.enabled
2094
+ };
2095
+ },
2096
+ update_pipeline_config: async (args) => {
2097
+ const client = getClient();
2098
+ const result = await client.updatePipelineConfig(args.repo_slug, {
2099
+ enabled: args.enabled
2100
+ });
2101
+ return {
2102
+ enabled: result.enabled
2103
+ };
1601
2104
  }
1602
2105
  };
1603
2106
 
@@ -1665,12 +2168,15 @@ var handlers4 = {
1665
2168
  var definitions5 = [
1666
2169
  {
1667
2170
  name: "list_commits",
1668
- description: "List commits in a repository.",
2171
+ description: "List a page of commits. Use include/exclude for commits between revisions (include=head, exclude=base); continue with next_cursor until has_more is false.",
1669
2172
  inputSchema: {
1670
2173
  type: "object",
1671
2174
  properties: {
1672
2175
  repo_slug: { type: "string", description: "Repository slug" },
1673
- branch: { type: "string", description: "Filter by branch name (optional)" },
2176
+ branch: { type: "string", description: "Branch, tag or SHA to include (alias for include)" },
2177
+ include: { type: "array", items: { type: "string" }, description: "Include commits reachable from these branches, tags or SHAs" },
2178
+ exclude: { type: "array", items: { type: "string" }, description: "Exclude commits reachable from these revisions" },
2179
+ cursor: { type: "string", description: "Opaque next_cursor from this tool; retains its page size and filters" },
1674
2180
  path: { type: "string", description: "Filter by file path - only commits that modified this path (optional)" },
1675
2181
  limit: { type: "number", description: "Maximum results (default: 20)", default: 20 }
1676
2182
  },
@@ -1691,17 +2197,45 @@ var definitions5 = [
1691
2197
  },
1692
2198
  {
1693
2199
  name: "compare_commits",
1694
- description: "Compare two commits or branches and see files changed.",
2200
+ description: "Compare base to head (branches, tags or SHAs). Returns a page of changed files and line counts; follow next_cursor for all files.",
1695
2201
  inputSchema: {
1696
2202
  type: "object",
1697
2203
  properties: {
1698
2204
  repo_slug: { type: "string", description: "Repository slug" },
1699
2205
  base: { type: "string", description: "Base commit hash or branch name" },
1700
- head: { type: "string", description: "Head commit hash or branch name" }
2206
+ head: { type: "string", description: "Head commit hash, tag or branch name" },
2207
+ path: { type: "string", description: "Optional file path filter" },
2208
+ limit: { type: "integer", minimum: 1, maximum: 100, default: 50 },
2209
+ cursor: { type: "string", description: "Opaque next_cursor; retains page size and filters" }
2210
+ },
2211
+ required: ["repo_slug", "base", "head"]
2212
+ }
2213
+ },
2214
+ {
2215
+ name: "get_commit_diff",
2216
+ description: "Get the raw Git-style diff from base to head. Continue large diffs with next_offset; pin revisions to commit hashes while paging. API-side diff limits still apply.",
2217
+ inputSchema: {
2218
+ type: "object",
2219
+ properties: {
2220
+ repo_slug: { type: "string" },
2221
+ base: { type: "string" },
2222
+ head: { type: "string" },
2223
+ path: { type: "string", description: "Optional file path filter" },
2224
+ offset: { type: "integer", minimum: 0, default: 0 },
2225
+ max_chars: { type: "integer", minimum: 1, maximum: 1e5, default: 2e4 }
1701
2226
  },
1702
2227
  required: ["repo_slug", "base", "head"]
1703
2228
  }
1704
2229
  },
2230
+ {
2231
+ name: "get_merge_base",
2232
+ description: "Get a best common ancestor of two branches, tags or commit hashes. Bitbucket returns one ancestor when multiple best ancestors exist.",
2233
+ inputSchema: {
2234
+ type: "object",
2235
+ properties: { repo_slug: { type: "string" }, base: { type: "string" }, head: { type: "string" } },
2236
+ required: ["repo_slug", "base", "head"]
2237
+ }
2238
+ },
1705
2239
  {
1706
2240
  name: "get_commit_statuses",
1707
2241
  description: "Get build/CI statuses for a commit.",
@@ -1736,18 +2270,18 @@ var definitions5 = [
1736
2270
  var handlers5 = {
1737
2271
  list_commits: async (args) => {
1738
2272
  const client = getClient();
1739
- const commits = await client.listCommits(args.repo_slug, {
2273
+ const page = await client.listCommitsPage(args.repo_slug, {
1740
2274
  branch: args.branch,
2275
+ include: args.include,
2276
+ exclude: args.exclude,
1741
2277
  path: args.path,
1742
- limit: validateLimit(args.limit || 20)
2278
+ cursor: args.cursor,
2279
+ limit: validateLimit(args.limit ?? 20)
1743
2280
  });
1744
2281
  return {
1745
- commits: commits.map((c) => ({
1746
- hash: truncateHash(c.hash),
1747
- message: c.message,
1748
- author: c.author?.raw,
1749
- date: c.date
1750
- }))
2282
+ commits: page.values.map((c) => ({ hash: truncateHash(c.hash), message: c.message, author: c.author?.raw, date: c.date })),
2283
+ has_more: page.has_more,
2284
+ next_cursor: page.next_cursor
1751
2285
  };
1752
2286
  },
1753
2287
  get_commit: async (args) => {
@@ -1766,24 +2300,33 @@ var handlers5 = {
1766
2300
  },
1767
2301
  compare_commits: async (args) => {
1768
2302
  const client = getClient();
1769
- const result = await client.compareCommits(
1770
- args.repo_slug,
1771
- args.base,
1772
- args.head
1773
- );
1774
- if (!result) {
1775
- return { error: `Could not compare ${args.base}..${args.head}` };
1776
- }
1777
- const files = result.values || [];
2303
+ const page = await client.compareCommitsPage(args.repo_slug, args.base, args.head, {
2304
+ path: args.path,
2305
+ limit: validateLimit(args.limit ?? 50),
2306
+ cursor: args.cursor
2307
+ });
2308
+ const files = page.values;
1778
2309
  return {
1779
- files: files.slice(0, 50).map((f) => ({
1780
- path: f.new?.path || f.old?.path,
1781
- status: f.status,
1782
- "+": f.lines_added || 0,
1783
- "-": f.lines_removed || 0
1784
- }))
2310
+ files: files.map((f) => ({ path: f.new?.path || f.old?.path, status: f.status, "+": f.lines_added || 0, "-": f.lines_removed || 0 })),
2311
+ has_more: page.has_more,
2312
+ next_cursor: page.next_cursor
1785
2313
  };
1786
2314
  },
2315
+ get_commit_diff: async (args) => {
2316
+ const offset = args.offset === void 0 ? 0 : Number(args.offset);
2317
+ const maxChars = args.max_chars === void 0 ? 2e4 : Number(args.max_chars);
2318
+ if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(maxChars) || maxChars < 1 || maxChars > 1e5) {
2319
+ return { success: false, error: "offset must be a nonnegative integer; max_chars must be an integer between 1 and 100000" };
2320
+ }
2321
+ const diff = await getClient().getCommitDiff(args.repo_slug, args.base, args.head, { path: args.path });
2322
+ if (diff === null) return notFoundResponse("Diff", `${args.base}..${args.head}`);
2323
+ const end = Math.min(offset + maxChars, diff.length);
2324
+ return { diff: diff.slice(offset, end), offset, next_offset: end < diff.length ? end : null, truncated: end < diff.length, total_chars: diff.length };
2325
+ },
2326
+ get_merge_base: async (args) => {
2327
+ const result = await getClient().getMergeBase(args.repo_slug, args.base, args.head);
2328
+ return result ? { hash: result.hash, message: result.message, author: result.author?.raw, date: result.date, parents: result.parents?.map((p) => truncateHash(p.hash)) } : notFoundResponse("Common ancestor", `${args.base}..${args.head}`);
2329
+ },
1787
2330
  get_commit_statuses: async (args) => {
1788
2331
  const client = getClient();
1789
2332
  const statuses = await client.getCommitStatuses(args.repo_slug, args.commit, {
@@ -2031,16 +2574,24 @@ var handlers7 = {
2031
2574
  var definitions8 = [
2032
2575
  {
2033
2576
  name: "list_tags",
2034
- description: "List tags in a repository.",
2577
+ description: "List a page of tags, optionally filtered with Bitbucket query syntax and sorted (name uses natural version order). Follow next_cursor for all tags.",
2035
2578
  inputSchema: {
2036
2579
  type: "object",
2037
2580
  properties: {
2038
2581
  repo_slug: { type: "string", description: "Repository slug" },
2039
- limit: { type: "number", description: "Maximum results (default: 50)", default: 50 }
2582
+ limit: { type: "integer", minimum: 1, maximum: 100, default: 50 },
2583
+ query: { type: "string", description: 'Bitbucket filter, e.g. name ~ "v2" or target.hash = "full-sha"' },
2584
+ sort: { type: "string", description: "Sort field, e.g. name, -name, -target.date", default: "-target.date" },
2585
+ cursor: { type: "string", description: "Opaque next_cursor; retains page size and filters" }
2040
2586
  },
2041
2587
  required: ["repo_slug"]
2042
2588
  }
2043
2589
  },
2590
+ {
2591
+ name: "get_tag",
2592
+ description: "Get a specific tag and its target commit without scanning tag pages.",
2593
+ inputSchema: { type: "object", properties: { repo_slug: { type: "string" }, tag_name: { type: "string" } }, required: ["repo_slug", "tag_name"] }
2594
+ },
2044
2595
  {
2045
2596
  name: "create_tag",
2046
2597
  description: "Create a new tag in a repository.",
@@ -2071,19 +2622,28 @@ var definitions8 = [
2071
2622
  var handlers8 = {
2072
2623
  list_tags: async (args) => {
2073
2624
  const client = getClient();
2074
- const tags = await client.listTags(args.repo_slug, {
2075
- limit: validateLimit(args.limit || 50)
2625
+ const page = await client.listTagsPage(args.repo_slug, {
2626
+ limit: validateLimit(args.limit ?? 50),
2627
+ query: args.query,
2628
+ sort: args.sort,
2629
+ cursor: args.cursor
2076
2630
  });
2077
2631
  return {
2078
- tags: tags.map((t) => ({
2632
+ tags: page.values.map((t) => ({
2079
2633
  name: t.name,
2080
2634
  target: truncateHash(t.target?.hash),
2081
2635
  message: t.message,
2082
2636
  date: t.target?.date,
2083
2637
  tagger: t.tagger?.raw
2084
- }))
2638
+ })),
2639
+ has_more: page.has_more,
2640
+ next_cursor: page.next_cursor
2085
2641
  };
2086
2642
  },
2643
+ get_tag: async (args) => {
2644
+ const tag = await getClient().getTag(args.repo_slug, args.tag_name);
2645
+ return tag ? { name: tag.name, target: tag.target?.hash, message: tag.message, date: tag.date, tagger: tag.tagger?.raw } : notFoundResponse("Tag", args.tag_name);
2646
+ },
2087
2647
  create_tag: async (args) => {
2088
2648
  const client = getClient();
2089
2649
  const result = await client.createTag(
@@ -2204,6 +2764,21 @@ var definitions10 = [
2204
2764
  required: ["repo_slug", "path"]
2205
2765
  }
2206
2766
  },
2767
+ {
2768
+ name: "get_file_history",
2769
+ description: "List file-history paths and full commit hashes at a branch, tag or SHA. Use get_commit for author, message and date. Follow next_cursor for older changes.",
2770
+ inputSchema: {
2771
+ type: "object",
2772
+ properties: {
2773
+ repo_slug: { type: "string" },
2774
+ path: { type: "string" },
2775
+ ref: { type: "string", default: "main" },
2776
+ limit: { type: "integer", minimum: 1, maximum: 100, default: 20 },
2777
+ cursor: { type: "string", description: "Opaque next_cursor; retains page size and filters" }
2778
+ },
2779
+ required: ["repo_slug", "path"]
2780
+ }
2781
+ },
2207
2782
  {
2208
2783
  name: "list_directory",
2209
2784
  description: "List contents of a directory in a repository. Browse repository structure without cloning.",
@@ -2234,6 +2809,17 @@ var handlers10 = {
2234
2809
  size: content.length
2235
2810
  };
2236
2811
  },
2812
+ get_file_history: async (args) => {
2813
+ const page = await getClient().getFileHistory(args.repo_slug, args.path, args.ref || "main", {
2814
+ limit: validateLimit(args.limit ?? 20),
2815
+ cursor: args.cursor
2816
+ });
2817
+ return {
2818
+ history: page.values.map((entry) => ({ path: entry.path, commit: { hash: entry.commit.hash } })),
2819
+ has_more: page.has_more,
2820
+ next_cursor: page.next_cursor
2821
+ };
2822
+ },
2237
2823
  list_directory: async (args) => {
2238
2824
  const client = getClient();
2239
2825
  const ref = args.ref || "main";
@@ -2873,7 +3459,7 @@ Summarize:
2873
3459
  }
2874
3460
 
2875
3461
  // src/index.ts
2876
- var VERSION = "0.10.0";
3462
+ var VERSION = "0.19.0";
2877
3463
  function createServer() {
2878
3464
  const server = new Server(
2879
3465
  {
@@ -2958,6 +3544,180 @@ function createServer() {
2958
3544
  });
2959
3545
  return server;
2960
3546
  }
3547
+ async function startStdio() {
3548
+ const server = createServer();
3549
+ const transport = new StdioServerTransport();
3550
+ await server.connect(transport);
3551
+ console.error(`Bitbucket MCP Server v${VERSION} started (stdio)`);
3552
+ }
3553
+ function createHttpHandler() {
3554
+ const sessions = /* @__PURE__ */ new Map();
3555
+ const auth = getAuthConfig();
3556
+ const proxy = auth ? getProxyConfig(auth) : null;
3557
+ return async (req, res) => {
3558
+ res.setHeader("Access-Control-Allow-Origin", "*");
3559
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS, DELETE");
3560
+ res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, mcp-session-id");
3561
+ res.setHeader("Access-Control-Expose-Headers", "mcp-session-id");
3562
+ if (req.method === "OPTIONS") {
3563
+ res.writeHead(204);
3564
+ res.end();
3565
+ return;
3566
+ }
3567
+ const clientIp = req.headers["x-forwarded-for"]?.split(",")[0]?.trim() ?? req.socket.remoteAddress ?? "unknown";
3568
+ res.on("finish", () => {
3569
+ console.error(
3570
+ `${req.method} ${redactUrlForLog(req.url ?? "")} -> ${res.statusCode} from ${clientIp}${callerLabel ? ` (${callerLabel})` : ""}`
3571
+ );
3572
+ });
3573
+ let callerLabel = "";
3574
+ if (req.method === "GET" && req.url === "/health") {
3575
+ res.writeHead(200, { "Content-Type": "application/json" });
3576
+ res.end(JSON.stringify({ status: "ok", version: VERSION }));
3577
+ return;
3578
+ }
3579
+ if (auth && req.method === "GET" && METADATA_PATHS.includes(req.url || "")) {
3580
+ res.writeHead(200, { "Content-Type": "application/json" });
3581
+ res.end(JSON.stringify(protectedResourceMetadata(auth)));
3582
+ return;
3583
+ }
3584
+ if (auth && proxy && req.method === "GET" && req.url === AS_METADATA_PATH) {
3585
+ res.writeHead(200, { "Content-Type": "application/json" });
3586
+ res.end(JSON.stringify(proxyAuthorizationServerMetadata(auth, proxy)));
3587
+ return;
3588
+ }
3589
+ if (auth && proxy && req.method === "GET" && req.url?.startsWith(AUTHORIZE_PATH)) {
3590
+ try {
3591
+ const { authorize } = await resolveUpstreamEndpoints(auth);
3592
+ const requestUrl = new URL(req.url, proxy.origin);
3593
+ const result = buildAuthorizeRedirect(requestUrl, proxy, authorize);
3594
+ if (result.status === 302) {
3595
+ res.writeHead(302, { Location: result.location });
3596
+ res.end();
3597
+ } else {
3598
+ res.writeHead(400, { "Content-Type": "application/json" });
3599
+ res.end(JSON.stringify({ error: result.error, error_description: result.description }));
3600
+ }
3601
+ } catch (error) {
3602
+ console.error(`Authorization request could not be forwarded: ${error}`);
3603
+ res.writeHead(502, { "Content-Type": "application/json" });
3604
+ res.end(JSON.stringify({ error: "authorization_server_unavailable" }));
3605
+ }
3606
+ return;
3607
+ }
3608
+ if (auth && proxy && req.method === "POST" && req.url === REGISTER_PATH) {
3609
+ let parsed = {};
3610
+ try {
3611
+ parsed = JSON.parse((await readHttpBody(req.iterator({ destroyOnReturn: false }))).toString("utf8") || "{}");
3612
+ } catch (error) {
3613
+ const status = error instanceof HttpInputError ? error.status : 400;
3614
+ res.writeHead(status, { "Content-Type": "application/json", Connection: "close" });
3615
+ res.end(JSON.stringify({ error: "invalid_request", error_description: error instanceof HttpInputError ? error.message : "Body must be JSON" }));
3616
+ return;
3617
+ }
3618
+ const result = registerClient(parsed, proxy);
3619
+ if (result.status === 201) {
3620
+ res.writeHead(201, { "Content-Type": "application/json" });
3621
+ res.end(JSON.stringify(result.body));
3622
+ } else {
3623
+ res.writeHead(result.status, { "Content-Type": "application/json" });
3624
+ res.end(JSON.stringify({ error: result.error, error_description: result.description }));
3625
+ }
3626
+ return;
3627
+ }
3628
+ if (auth && proxy && req.method === "POST" && req.url === TOKEN_PATH) {
3629
+ try {
3630
+ const body = await readHttpBody(req.iterator({ destroyOnReturn: false }));
3631
+ const { token } = await resolveUpstreamEndpoints(auth);
3632
+ const upstream = await forwardTokenRequest(
3633
+ body.toString("utf8"),
3634
+ req.headers.authorization,
3635
+ token
3636
+ );
3637
+ res.writeHead(upstream.status, { "Content-Type": upstream.contentType });
3638
+ res.end(upstream.body);
3639
+ } catch (error) {
3640
+ if (error instanceof HttpInputError) {
3641
+ res.writeHead(error.status, { "Content-Type": "application/json", Connection: "close" });
3642
+ res.end(JSON.stringify({ error: "invalid_request", error_description: error.message }));
3643
+ return;
3644
+ }
3645
+ console.error(`Token request could not be forwarded: ${error}`);
3646
+ res.writeHead(502, { "Content-Type": "application/json" });
3647
+ res.end(JSON.stringify({ error: "authorization_server_unavailable" }));
3648
+ }
3649
+ return;
3650
+ }
3651
+ if (req.url !== "/mcp") {
3652
+ res.writeHead(404);
3653
+ res.end("Not found");
3654
+ return;
3655
+ }
3656
+ if (auth) {
3657
+ const result = await verifyBearer(req.headers.authorization, auth);
3658
+ if (result.ok) {
3659
+ callerLabel = result.caller ?? result.subject ?? "authenticated";
3660
+ }
3661
+ if (!result.ok) {
3662
+ res.writeHead(result.status, {
3663
+ "Content-Type": "application/json",
3664
+ "WWW-Authenticate": wwwAuthenticate(auth, result)
3665
+ });
3666
+ res.end(JSON.stringify({ error: result.error, error_description: result.description }));
3667
+ return;
3668
+ }
3669
+ }
3670
+ let parsedBody;
3671
+ if (req.method === "POST") {
3672
+ try {
3673
+ parsedBody = parseMcpBody(await readHttpBody(req.iterator({ destroyOnReturn: false })));
3674
+ } catch (error) {
3675
+ const status = error instanceof HttpInputError ? error.status : 400;
3676
+ res.writeHead(status, { "Content-Type": "application/json", Connection: "close" });
3677
+ res.end(JSON.stringify({ jsonrpc: "2.0", id: null, error: {
3678
+ code: -32600,
3679
+ message: error instanceof HttpInputError ? error.message : "Invalid JSON"
3680
+ } }));
3681
+ return;
3682
+ }
3683
+ }
3684
+ const sessionId = req.headers["mcp-session-id"];
3685
+ if (sessionId && sessions.has(sessionId)) {
3686
+ const session = sessions.get(sessionId);
3687
+ await session.transport.handleRequest(req, res, parsedBody);
3688
+ return;
3689
+ }
3690
+ if (req.method === "POST" && !sessionId) {
3691
+ const transport = new StreamableHTTPServerTransport({
3692
+ sessionIdGenerator: () => crypto.randomUUID()
3693
+ });
3694
+ const server = createServer();
3695
+ transport.onclose = () => {
3696
+ const sid = transport._sessionId;
3697
+ if (sid) sessions.delete(sid);
3698
+ server.close();
3699
+ };
3700
+ await server.connect(transport);
3701
+ await transport.handleRequest(req, res, parsedBody);
3702
+ const newSessionId = res.getHeader("mcp-session-id");
3703
+ if (newSessionId) {
3704
+ sessions.set(newSessionId, { server, transport });
3705
+ }
3706
+ return;
3707
+ }
3708
+ res.writeHead(400, { "Content-Type": "application/json" });
3709
+ res.end(JSON.stringify({ error: "Invalid or missing session" }));
3710
+ };
3711
+ }
3712
+ async function startHttp() {
3713
+ const port = parseInt(process.env.PORT || "3000", 10);
3714
+ const auth = getAuthConfig();
3715
+ const httpServer = createHttpServer(createHttpHandler());
3716
+ httpServer.listen(port, () => {
3717
+ const mode = auth ? `OAuth required (issuer ${auth.issuer})` : "unauthenticated";
3718
+ console.error(`Bitbucket MCP Server v${VERSION} started (http) on port ${port} \u2014 ${mode}`);
3719
+ });
3720
+ }
2961
3721
  async function main() {
2962
3722
  try {
2963
3723
  getSettings();
@@ -2965,12 +3725,19 @@ async function main() {
2965
3725
  console.error("Configuration error:", error instanceof Error ? error.message : error);
2966
3726
  process.exit(1);
2967
3727
  }
2968
- const server = createServer();
2969
- const transport = new StdioServerTransport();
2970
- await server.connect(transport);
2971
- console.error(`Bitbucket MCP Server v${VERSION} started`);
3728
+ const transport = process.env.MCP_TRANSPORT || (process.argv.includes("--http") ? "http" : "stdio");
3729
+ if (transport === "http") {
3730
+ await startHttp();
3731
+ } else {
3732
+ await startStdio();
3733
+ }
2972
3734
  }
2973
- main().catch((error) => {
2974
- console.error("Fatal error:", error);
2975
- process.exit(1);
2976
- });
3735
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
3736
+ main().catch((error) => {
3737
+ console.error("Fatal error:", error);
3738
+ process.exit(1);
3739
+ });
3740
+ }
3741
+ export {
3742
+ createHttpHandler
3743
+ };