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/Dockerfile +9 -1
- package/dist/index.d.ts +16 -0
- package/dist/index.js +906 -139
- package/package.json +14 -12
- package/scripts/check-ca-bundles.cjs +27 -0
- package/smithery.yaml +10 -4
- package/src/auth.ts +330 -0
- package/src/client.ts +253 -123
- package/src/http-input.ts +36 -0
- package/src/index.ts +274 -13
- package/src/oauth-proxy.ts +298 -0
- package/src/prompts.ts +1 -1
- package/src/settings.ts +51 -10
- package/src/tools/commits.ts +65 -33
- package/src/tools/index.ts +0 -3
- package/src/tools/pipelines.ts +41 -0
- package/src/tools/source.ts +23 -0
- package/src/tools/tags.ts +21 -6
- package/src/types.ts +9 -0
- package/tests/auth.test.ts +277 -0
- package/tests/history.test.ts +284 -0
- package/tests/http-handler.test.ts +67 -0
- package/tests/http-input.test.ts +40 -0
- package/tests/network-resilience.test.ts +113 -0
- package/tests/oauth-proxy.test.ts +286 -0
- package/tests/settings.test.ts +110 -0
- package/tests/stdio.test.ts +20 -0
- package/tests/test-remote.mjs +22 -0
package/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().
|
|
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:
|
|
33
|
-
|
|
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:
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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 (
|
|
141
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
statusCode,
|
|
160
|
-
method,
|
|
161
|
-
path
|
|
162
|
-
);
|
|
523
|
+
await sleep(wait);
|
|
524
|
+
slept += wait;
|
|
525
|
+
backoff *= 2;
|
|
526
|
+
continue;
|
|
163
527
|
}
|
|
164
|
-
throw
|
|
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(
|
|
536
|
+
throw new BitbucketError("Request retry budget exhausted", void 0, method, path);
|
|
168
537
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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
|
|
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
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
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
|
-
|
|
570
|
-
|
|
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
|
|
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
|
|
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: "
|
|
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
|
|
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
|
|
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
|
-
|
|
2278
|
+
cursor: args.cursor,
|
|
2279
|
+
limit: validateLimit(args.limit ?? 20)
|
|
1743
2280
|
});
|
|
1744
2281
|
return {
|
|
1745
|
-
commits:
|
|
1746
|
-
|
|
1747
|
-
|
|
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
|
|
1770
|
-
args.
|
|
1771
|
-
args.
|
|
1772
|
-
args.
|
|
1773
|
-
);
|
|
1774
|
-
|
|
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.
|
|
1780
|
-
|
|
1781
|
-
|
|
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
|
|
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: "
|
|
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
|
|
2075
|
-
limit: validateLimit(args.limit
|
|
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:
|
|
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.
|
|
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
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
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
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
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
|
+
};
|