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/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) {
@@ -582,21 +961,65 @@ var BitbucketClient = class _BitbucketClient {
582
961
  });
583
962
  }
584
963
  async getBranch(repoSlug, branchName) {
585
- return this.request("GET", this.repoPath(repoSlug, "refs", "branches", branchName));
964
+ return this.request("GET", this.repoPath(repoSlug, "refs", "branches", this.encodeRef(branchName)));
586
965
  }
587
966
  // ==================== COMMITS ====================
588
967
  async listCommits(repoSlug, options = {}) {
589
968
  return this.paginatedList(this.repoPath(repoSlug, "commits"), {
590
- limit: options.limit || 20,
591
- 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,
592
981
  path: options.path
593
982
  });
594
983
  }
595
984
  async getCommit(repoSlug, commit) {
596
- 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
+ });
1006
+ }
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)}`;
597
1010
  }
598
- async compareCommits(repoSlug, base, head) {
599
- return this.request("GET", this.repoPath(repoSlug, "diffstat", `${base}..${head}`));
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
+ });
600
1023
  }
601
1024
  async getCommitStatuses(repoSlug, commit, options = {}) {
602
1025
  return this.paginatedList(this.repoPath(repoSlug, "commit", commit, "statuses"), {
@@ -683,10 +1106,22 @@ var BitbucketClient = class _BitbucketClient {
683
1106
  // ==================== TAGS ====================
684
1107
  async listTags(repoSlug, options = {}) {
685
1108
  return this.paginatedList(this.repoPath(repoSlug, "refs", "tags"), {
686
- limit: options.limit || 50,
687
- 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
688
1120
  });
689
1121
  }
1122
+ getTag(repoSlug, tagName) {
1123
+ return this.request("GET", this.repoPath(repoSlug, "refs", "tags", this.encodeRef(tagName)));
1124
+ }
690
1125
  async createTag(repoSlug, name, target, message) {
691
1126
  const payload = {
692
1127
  name,
@@ -706,7 +1141,7 @@ var BitbucketClient = class _BitbucketClient {
706
1141
  return result;
707
1142
  }
708
1143
  async deleteTag(repoSlug, tagName) {
709
- await this.request("DELETE", this.repoPath(repoSlug, "refs", "tags", tagName));
1144
+ await this.request("DELETE", this.repoPath(repoSlug, "refs", "tags", this.encodeRef(tagName)));
710
1145
  }
711
1146
  // ==================== BRANCH RESTRICTIONS ====================
712
1147
  async listBranchRestrictions(repoSlug, options = {}) {
@@ -746,10 +1181,10 @@ var BitbucketClient = class _BitbucketClient {
746
1181
  }
747
1182
  // ==================== SOURCE ====================
748
1183
  async getFileContent(repoSlug, path, ref = "main") {
749
- return this.requestText(this.repoPath(repoSlug, "src", ref, path));
1184
+ return this.requestText(this.repoPath(repoSlug, "src", this.encodeRef(ref), this.encodeFilePath(path)));
750
1185
  }
751
1186
  async listDirectory(repoSlug, path = "", options = {}) {
752
- 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"));
753
1188
  return this.paginatedList(endpoint, { limit: options.limit || 100 });
754
1189
  }
755
1190
  // ==================== PERMISSIONS ====================
@@ -1733,12 +2168,15 @@ var handlers4 = {
1733
2168
  var definitions5 = [
1734
2169
  {
1735
2170
  name: "list_commits",
1736
- 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.",
1737
2172
  inputSchema: {
1738
2173
  type: "object",
1739
2174
  properties: {
1740
2175
  repo_slug: { type: "string", description: "Repository slug" },
1741
- 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" },
1742
2180
  path: { type: "string", description: "Filter by file path - only commits that modified this path (optional)" },
1743
2181
  limit: { type: "number", description: "Maximum results (default: 20)", default: 20 }
1744
2182
  },
@@ -1759,17 +2197,45 @@ var definitions5 = [
1759
2197
  },
1760
2198
  {
1761
2199
  name: "compare_commits",
1762
- 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.",
1763
2201
  inputSchema: {
1764
2202
  type: "object",
1765
2203
  properties: {
1766
2204
  repo_slug: { type: "string", description: "Repository slug" },
1767
2205
  base: { type: "string", description: "Base commit hash or branch name" },
1768
- 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" }
1769
2210
  },
1770
2211
  required: ["repo_slug", "base", "head"]
1771
2212
  }
1772
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 }
2226
+ },
2227
+ required: ["repo_slug", "base", "head"]
2228
+ }
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
+ },
1773
2239
  {
1774
2240
  name: "get_commit_statuses",
1775
2241
  description: "Get build/CI statuses for a commit.",
@@ -1804,18 +2270,18 @@ var definitions5 = [
1804
2270
  var handlers5 = {
1805
2271
  list_commits: async (args) => {
1806
2272
  const client = getClient();
1807
- const commits = await client.listCommits(args.repo_slug, {
2273
+ const page = await client.listCommitsPage(args.repo_slug, {
1808
2274
  branch: args.branch,
2275
+ include: args.include,
2276
+ exclude: args.exclude,
1809
2277
  path: args.path,
1810
- limit: validateLimit(args.limit || 20)
2278
+ cursor: args.cursor,
2279
+ limit: validateLimit(args.limit ?? 20)
1811
2280
  });
1812
2281
  return {
1813
- commits: commits.map((c) => ({
1814
- hash: truncateHash(c.hash),
1815
- message: c.message,
1816
- author: c.author?.raw,
1817
- date: c.date
1818
- }))
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
1819
2285
  };
1820
2286
  },
1821
2287
  get_commit: async (args) => {
@@ -1834,24 +2300,33 @@ var handlers5 = {
1834
2300
  },
1835
2301
  compare_commits: async (args) => {
1836
2302
  const client = getClient();
1837
- const result = await client.compareCommits(
1838
- args.repo_slug,
1839
- args.base,
1840
- args.head
1841
- );
1842
- if (!result) {
1843
- return { error: `Could not compare ${args.base}..${args.head}` };
1844
- }
1845
- 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;
1846
2309
  return {
1847
- files: files.slice(0, 50).map((f) => ({
1848
- path: f.new?.path || f.old?.path,
1849
- status: f.status,
1850
- "+": f.lines_added || 0,
1851
- "-": f.lines_removed || 0
1852
- }))
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
1853
2313
  };
1854
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
+ },
1855
2330
  get_commit_statuses: async (args) => {
1856
2331
  const client = getClient();
1857
2332
  const statuses = await client.getCommitStatuses(args.repo_slug, args.commit, {
@@ -2099,16 +2574,24 @@ var handlers7 = {
2099
2574
  var definitions8 = [
2100
2575
  {
2101
2576
  name: "list_tags",
2102
- 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.",
2103
2578
  inputSchema: {
2104
2579
  type: "object",
2105
2580
  properties: {
2106
2581
  repo_slug: { type: "string", description: "Repository slug" },
2107
- 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" }
2108
2586
  },
2109
2587
  required: ["repo_slug"]
2110
2588
  }
2111
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
+ },
2112
2595
  {
2113
2596
  name: "create_tag",
2114
2597
  description: "Create a new tag in a repository.",
@@ -2139,19 +2622,28 @@ var definitions8 = [
2139
2622
  var handlers8 = {
2140
2623
  list_tags: async (args) => {
2141
2624
  const client = getClient();
2142
- const tags = await client.listTags(args.repo_slug, {
2143
- 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
2144
2630
  });
2145
2631
  return {
2146
- tags: tags.map((t) => ({
2632
+ tags: page.values.map((t) => ({
2147
2633
  name: t.name,
2148
2634
  target: truncateHash(t.target?.hash),
2149
2635
  message: t.message,
2150
2636
  date: t.target?.date,
2151
2637
  tagger: t.tagger?.raw
2152
- }))
2638
+ })),
2639
+ has_more: page.has_more,
2640
+ next_cursor: page.next_cursor
2153
2641
  };
2154
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
+ },
2155
2647
  create_tag: async (args) => {
2156
2648
  const client = getClient();
2157
2649
  const result = await client.createTag(
@@ -2272,6 +2764,21 @@ var definitions10 = [
2272
2764
  required: ["repo_slug", "path"]
2273
2765
  }
2274
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
+ },
2275
2782
  {
2276
2783
  name: "list_directory",
2277
2784
  description: "List contents of a directory in a repository. Browse repository structure without cloning.",
@@ -2302,6 +2809,17 @@ var handlers10 = {
2302
2809
  size: content.length
2303
2810
  };
2304
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
+ },
2305
2823
  list_directory: async (args) => {
2306
2824
  const client = getClient();
2307
2825
  const ref = args.ref || "main";
@@ -2941,7 +3459,7 @@ Summarize:
2941
3459
  }
2942
3460
 
2943
3461
  // src/index.ts
2944
- var VERSION = "0.10.0";
3462
+ var VERSION = "0.19.0";
2945
3463
  function createServer() {
2946
3464
  const server = new Server(
2947
3465
  {
@@ -3026,6 +3544,180 @@ function createServer() {
3026
3544
  });
3027
3545
  return server;
3028
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
+ }
3029
3721
  async function main() {
3030
3722
  try {
3031
3723
  getSettings();
@@ -3033,12 +3725,19 @@ async function main() {
3033
3725
  console.error("Configuration error:", error instanceof Error ? error.message : error);
3034
3726
  process.exit(1);
3035
3727
  }
3036
- const server = createServer();
3037
- const transport = new StdioServerTransport();
3038
- await server.connect(transport);
3039
- 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
+ }
3040
3734
  }
3041
- main().catch((error) => {
3042
- console.error("Fatal error:", error);
3043
- process.exit(1);
3044
- });
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
+ };