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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-server-bitbucket",
3
- "version": "0.12.0",
3
+ "version": "0.19.0",
4
4
  "description": "MCP server for Bitbucket API operations",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -37,21 +37,23 @@
37
37
  "url": "https://github.com/JaviMaligno/mcp-server-bitbucket/issues"
38
38
  },
39
39
  "engines": {
40
- "node": ">=18.0.0"
40
+ "node": "^22.23.4 || ^24.22.1"
41
41
  },
42
42
  "dependencies": {
43
- "@modelcontextprotocol/sdk": "^1.0.0",
44
- "axios": "^1.7.0",
45
- "zod": "^3.23.0"
43
+ "@modelcontextprotocol/sdk": "^1.32.1",
44
+ "axios": "^1.20.0",
45
+ "jose": "^6.2.9",
46
+ "zod": "^3.25.76"
46
47
  },
47
48
  "devDependencies": {
48
- "@types/node": "^20.0.0",
49
- "tsup": "^8.0.0",
50
- "typescript": "^5.4.0",
51
- "vitest": "^2.0.0",
49
+ "@types/node": "^24.0.0",
50
+ "@typescript-eslint/eslint-plugin": "^8.72.0",
51
+ "@typescript-eslint/parser": "^8.72.0",
52
52
  "eslint": "^8.57.0",
53
- "@typescript-eslint/eslint-plugin": "^7.0.0",
54
- "@typescript-eslint/parser": "^7.0.0"
53
+ "tsup": "8.5.0",
54
+ "typescript": "^5.4.0",
55
+ "vitest": "^4.1.12",
56
+ "vite": "^8.3.5",
57
+ "esbuild": "^0.28.1"
55
58
  }
56
59
  }
57
-
@@ -0,0 +1,27 @@
1
+ // Image-build gate: verify the final Node binary and its public trust stores.
2
+ const assert = require('node:assert/strict');
3
+ const { X509Certificate } = require('node:crypto');
4
+ const { readFileSync } = require('node:fs');
5
+ const { rootCertificates } = require('node:tls');
6
+
7
+ const compromised = '86a1ecba089c4a8d3bbe2734c612ba341d813e043cf9e8a862cd5c57a36bbe6b';
8
+ const floors = { 22: [23, 4], 24: [22, 1], 26: [12, 1] };
9
+ const [major, minor, patch] = process.versions.node.split('.').map(Number);
10
+ const floor = floors[major];
11
+ assert(floor && (minor > floor[0] || (minor === floor[0] && patch >= floor[1])),
12
+ `Unpatched or unsupported Node runtime: ${process.versions.node}`);
13
+ const pem = readFileSync('/etc/ssl/certs/ca-certificates.crt', 'utf8');
14
+ const bundles = {
15
+ node: rootCertificates,
16
+ os: pem.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g) || [],
17
+ };
18
+ const stores = {};
19
+ for (const [name, certificates] of Object.entries(bundles)) {
20
+ assert(certificates.length > 0, `Empty ${name} trust store`);
21
+ for (const certificate of certificates) {
22
+ const fingerprint = new X509Certificate(certificate).fingerprint256.replaceAll(':', '').toLowerCase();
23
+ assert.notEqual(fingerprint, compromised, `Compromised CA present in ${name}`);
24
+ }
25
+ stores[name] = { certificates: certificates.length, compromisedRootPresent: false };
26
+ }
27
+ console.log(JSON.stringify({ node: process.versions.node, stores }));
package/smithery.yaml CHANGED
@@ -12,10 +12,16 @@ startCommand:
12
12
  description: Bitbucket workspace slug (e.g., 'my-team')
13
13
  BITBUCKET_EMAIL:
14
14
  type: string
15
- description: Account email for Bitbucket Basic Auth
15
+ description: Account email for Bitbucket Basic Auth (required for basic auth)
16
16
  BITBUCKET_API_TOKEN:
17
17
  type: string
18
- description: Repository access token from Bitbucket
18
+ description: Atlassian API token used with Basic auth
19
+ BITBUCKET_OAUTH_TOKEN:
20
+ type: string
21
+ description: Workspace/project/repository access token sent as `Authorization: Bearer`
22
+ BITBUCKET_AUTH_TYPE:
23
+ type: string
24
+ description: Force auth mode - `basic` or `bearer` (auto-detected when unset)
19
25
  API_TIMEOUT:
20
26
  type: number
21
27
  default: 30
@@ -26,8 +32,6 @@ startCommand:
26
32
  description: Max retry attempts for rate limiting (max 10)
27
33
  required:
28
34
  - BITBUCKET_WORKSPACE
29
- - BITBUCKET_EMAIL
30
- - BITBUCKET_API_TOKEN
31
35
  commandFunction: |
32
36
  (config) => ({
33
37
  command: 'node',
@@ -36,6 +40,8 @@ startCommand:
36
40
  BITBUCKET_WORKSPACE: config.BITBUCKET_WORKSPACE,
37
41
  BITBUCKET_EMAIL: config.BITBUCKET_EMAIL,
38
42
  BITBUCKET_API_TOKEN: config.BITBUCKET_API_TOKEN,
43
+ BITBUCKET_OAUTH_TOKEN: config.BITBUCKET_OAUTH_TOKEN,
44
+ BITBUCKET_AUTH_TYPE: config.BITBUCKET_AUTH_TYPE,
39
45
  API_TIMEOUT: config.API_TIMEOUT ? String(config.API_TIMEOUT) : "30",
40
46
  MAX_RETRIES: config.MAX_RETRIES ? String(config.MAX_RETRIES) : "3"
41
47
  }
package/src/auth.ts ADDED
@@ -0,0 +1,330 @@
1
+ /**
2
+ * OAuth 2.1 bearer-token protection for the HTTP transport.
3
+ *
4
+ * The remote server holds a single company-owned Bitbucket credential, so
5
+ * anyone who can reach /mcp can use it. This module gates that endpoint on a
6
+ * bearer token issued by an external authorization server (Microsoft Entra ID
7
+ * in our deployment), following the MCP authorization spec: the server acts as
8
+ * an OAuth *resource server*, advertises where to get a token via
9
+ * /.well-known/oauth-protected-resource (RFC 9728), and answers unauthenticated
10
+ * requests with 401 + WWW-Authenticate so the client can start the flow.
11
+ *
12
+ * Note this authenticates *who may use the server*, not who acts on Bitbucket:
13
+ * calls still reach Bitbucket as the service identity.
14
+ *
15
+ * Disabled unless both MCP_OAUTH_ISSUER and MCP_OAUTH_AUDIENCE are set, so an
16
+ * unconfigured deployment keeps behaving exactly as before.
17
+ *
18
+ * Environment variables:
19
+ * - MCP_OAUTH_ISSUER: token issuer, e.g. https://login.microsoftonline.com/<tenant>/v2.0
20
+ * - MCP_OAUTH_AUDIENCE: expected `aud`, e.g. api://<app-id>
21
+ * - MCP_OAUTH_JWKS_URI: override for the signing keys (derived from the issuer otherwise)
22
+ * - MCP_OAUTH_REQUIRED_SCOPE: scope(s) the token must carry, comma-separated when
23
+ * more than one is acceptable, e.g. "mcp.access,mcp.invoke" (Entra names the
24
+ * delegated scope and the application role differently, and a token only ever
25
+ * carries one of the two)
26
+ * - MCP_OAUTH_RESOURCE: identifier advertised as the protected resource, when it
27
+ * cannot be the server's own URL. The spec wants the canonical MCP endpoint,
28
+ * and clients send it as the RFC 8707 `resource` parameter — but Entra rejects
29
+ * that with AADSTS9010010 ("resource parameter doesn't match the requested
30
+ * scopes") unless it is the API's Application ID URI, and it only accepts
31
+ * https identifiers on domains verified in the tenant. So against Entra this
32
+ * is set to api://<app-id>.
33
+ * - MCP_OAUTH_AS_METADATA: 'proxy' (default) to publish the authorization server
34
+ * metadata ourselves, or 'issuer' to point clients straight at the issuer.
35
+ * Entra ID does not serve RFC 8414 metadata at all — /.well-known/oauth-authorization-server
36
+ * is a 404 on every variant — so a client that follows the MCP spec stops there
37
+ * and never reaches the login page. Proxying republishes the issuer's OpenID
38
+ * configuration at our own well-known path, which is what unblocks the flow.
39
+ * - MCP_OAUTH_SCOPES_SUPPORTED: scope(s) advertised to clients, when they differ
40
+ * from the ones validated. Entra hands out `scp: mcp.access` but expects the
41
+ * client to *request* `api://<app-id>/mcp.access`, so the full URI goes here
42
+ * - MCP_PUBLIC_URL: public URL of this server. The resource identifier is the MCP
43
+ * endpoint itself (<public-url>/mcp), which is the most specific canonical URI
44
+ * per RFC 8707, and the metadata is served at the path RFC 9728 derives from
45
+ * it: /.well-known/oauth-protected-resource/mcp
46
+ */
47
+
48
+ import { createRemoteJWKSet, jwtVerify, type JWTPayload, type JWTVerifyGetKey } from 'jose';
49
+
50
+ export interface AuthConfig {
51
+ /** Token issuer that must match the `iss` claim. */
52
+ issuer: string;
53
+ /** Expected `aud` claim — the identifier of this API. */
54
+ audience: string;
55
+ /** Scopes the token must carry at least one of, if any. */
56
+ requiredScopes?: string[];
57
+ /** Scopes advertised in the metadata document; defaults to requiredScopes. */
58
+ advertisedScopes?: string[];
59
+ /** Public URL of this server; anchors the well-known paths. */
60
+ resourceUrl: string;
61
+ /** What we advertise as the resource — the URL itself unless overridden. */
62
+ resourceIdentifier: string;
63
+ /** When true, we publish the authorization server metadata ourselves. */
64
+ proxyAuthorizationServerMetadata: boolean;
65
+ /** Key source used to verify token signatures. */
66
+ jwks: JWTVerifyGetKey;
67
+ }
68
+
69
+ export interface AuthFailure {
70
+ ok: false;
71
+ /** HTTP status to answer with: 401 for a missing/invalid token, 403 for a valid one lacking the scope. */
72
+ status: 401 | 403;
73
+ /** OAuth error code for the WWW-Authenticate header. */
74
+ error: 'invalid_token' | 'insufficient_scope';
75
+ description: string;
76
+ }
77
+
78
+ export interface AuthSuccess {
79
+ ok: true;
80
+ /** `sub` claim, useful for attributing calls in logs. */
81
+ subject?: string;
82
+ /** Human-readable caller, when the token carries one. */
83
+ caller?: string;
84
+ }
85
+
86
+ export type AuthResult = AuthSuccess | AuthFailure;
87
+
88
+ /**
89
+ * Entra ID publishes its signing keys next to the issuer; the v2.0 suffix in
90
+ * the issuer is not part of the discovery path.
91
+ */
92
+ export function defaultJwksUri(issuer: string): string {
93
+ const base = issuer.replace(/\/+$/, '').replace(/\/v2\.0$/, '');
94
+ return `${base}/discovery/v2.0/keys`;
95
+ }
96
+
97
+ /**
98
+ * Build the auth configuration from the environment, or null when OAuth is not
99
+ * configured (the server then serves /mcp unauthenticated, as it did before).
100
+ *
101
+ * `jwksOverride` exists for tests, which cannot fetch a remote key set.
102
+ */
103
+ export function getAuthConfig(
104
+ env: NodeJS.ProcessEnv = process.env,
105
+ jwksOverride?: JWTVerifyGetKey
106
+ ): AuthConfig | null {
107
+ const issuer = (env.MCP_OAUTH_ISSUER || '').trim();
108
+ const audience = (env.MCP_OAUTH_AUDIENCE || '').trim();
109
+
110
+ if (!issuer || !audience) {
111
+ return null;
112
+ }
113
+
114
+ const jwksUri = (env.MCP_OAUTH_JWKS_URI || '').trim() || defaultJwksUri(issuer);
115
+ const requiredScopes = parseScopes(env.MCP_OAUTH_REQUIRED_SCOPE);
116
+ const advertisedScopes = parseScopes(env.MCP_OAUTH_SCOPES_SUPPORTED) ?? requiredScopes;
117
+ const publicUrl = (env.MCP_PUBLIC_URL || '').trim().replace(/\/+$/, '');
118
+ // The resource is the MCP endpoint, not the host: clients ask for the most
119
+ // specific URI they can, and the audience check depends on this matching.
120
+ const resourceUrl = publicUrl ? `${publicUrl}/mcp` : audience;
121
+ const resourceIdentifier = (env.MCP_OAUTH_RESOURCE || '').trim() || resourceUrl;
122
+
123
+ return {
124
+ issuer,
125
+ audience,
126
+ requiredScopes,
127
+ advertisedScopes,
128
+ resourceUrl,
129
+ resourceIdentifier,
130
+ proxyAuthorizationServerMetadata: (env.MCP_OAUTH_AS_METADATA || 'proxy').trim() !== 'issuer',
131
+ jwks: jwksOverride ?? createRemoteJWKSet(new URL(jwksUri)),
132
+ };
133
+ }
134
+
135
+ /** Split a comma- or space-separated scope list; undefined when empty. */
136
+ function parseScopes(raw: string | undefined): string[] | undefined {
137
+ const scopes = (raw || '').split(/[\s,]+/).filter(Boolean);
138
+ return scopes.length > 0 ? scopes : undefined;
139
+ }
140
+
141
+ /**
142
+ * RFC 9728 metadata telling clients which authorization server issues tokens
143
+ * for this resource. Claude fetches this after a 401 to start the OAuth flow.
144
+ */
145
+ export function protectedResourceMetadata(config: AuthConfig): Record<string, unknown> {
146
+ // Advertise what clients must request; when that is not spelled out, the
147
+ // scopes we validate are the best available answer.
148
+ const scopes = config.advertisedScopes ?? config.requiredScopes;
149
+
150
+ // Point clients at whoever actually serves RFC 8414 metadata: ourselves when
151
+ // proxying (because the issuer does not), otherwise the issuer.
152
+ const authorizationServer = config.proxyAuthorizationServerMetadata
153
+ ? serverOrigin(config)
154
+ : config.issuer;
155
+
156
+ return {
157
+ resource: config.resourceIdentifier,
158
+ authorization_servers: [authorizationServer],
159
+ bearer_methods_supported: ['header'],
160
+ ...(scopes ? { scopes_supported: scopes } : {}),
161
+ };
162
+ }
163
+
164
+ /**
165
+ * Paths the metadata document is served from. RFC 9728 derives the path from the
166
+ * resource identifier, so a resource at /mcp is described at
167
+ * /.well-known/oauth-protected-resource/mcp; the bare path is kept too because
168
+ * some clients only look there.
169
+ */
170
+ export const METADATA_PATHS = [
171
+ '/.well-known/oauth-protected-resource/mcp',
172
+ '/.well-known/oauth-protected-resource',
173
+ ];
174
+
175
+ /** Origin this server is reachable at, derived from the resource identifier. */
176
+ export function serverOrigin(config: AuthConfig): string {
177
+ return config.resourceUrl.replace(/\/mcp$/, '');
178
+ }
179
+
180
+ /** Absolute URL of the metadata document, as advertised to clients. */
181
+ export function metadataUrl(config: AuthConfig): string {
182
+ return `${serverOrigin(config)}${METADATA_PATHS[0]}`;
183
+ }
184
+
185
+ /** Where RFC 8414 metadata is served from when we publish it ourselves. */
186
+ export const AS_METADATA_PATH = '/.well-known/oauth-authorization-server';
187
+
188
+ interface CachedMetadata {
189
+ document: Record<string, unknown>;
190
+ fetchedAt: number;
191
+ }
192
+
193
+ let asMetadataCache: CachedMetadata | null = null;
194
+ const AS_METADATA_TTL_MS = 60 * 60 * 1000;
195
+
196
+ /**
197
+ * Authorization server metadata to publish on our own well-known path.
198
+ *
199
+ * Built from the issuer's OpenID configuration — the one document Entra does
200
+ * serve — so the endpoints are always whatever the issuer currently advertises,
201
+ * rather than a copy that silently goes stale. PKCE is asserted because the MCP
202
+ * spec requires it of clients and Entra supports it without listing it.
203
+ */
204
+ export async function authorizationServerMetadata(
205
+ config: AuthConfig,
206
+ fetchImpl: typeof fetch = fetch,
207
+ now: number = Date.now()
208
+ ): Promise<Record<string, unknown>> {
209
+ if (asMetadataCache && now - asMetadataCache.fetchedAt < AS_METADATA_TTL_MS) {
210
+ return asMetadataCache.document;
211
+ }
212
+
213
+ const url = `${config.issuer.replace(/\/+$/, '')}/.well-known/openid-configuration`;
214
+ const response = await fetchImpl(url);
215
+ if (!response.ok) {
216
+ throw new Error(`Could not read OpenID configuration from ${url}: HTTP ${response.status}`);
217
+ }
218
+
219
+ const upstream = (await response.json()) as Record<string, unknown>;
220
+ const document = {
221
+ ...upstream,
222
+ code_challenge_methods_supported: upstream.code_challenge_methods_supported ?? ['S256'],
223
+ grant_types_supported: upstream.grant_types_supported ?? ['authorization_code', 'refresh_token'],
224
+ };
225
+
226
+ asMetadataCache = { document, fetchedAt: now };
227
+ return document;
228
+ }
229
+
230
+ /** Drop the cached metadata (tests, and after a configuration change). */
231
+ export function resetAuthorizationServerMetadataCache(): void {
232
+ asMetadataCache = null;
233
+ }
234
+
235
+ /**
236
+ * Value for the WWW-Authenticate header, pointing at the metadata document so
237
+ * the client can discover where to authenticate.
238
+ */
239
+ export function wwwAuthenticate(config: AuthConfig, failure?: AuthFailure): string {
240
+ const parts = [
241
+ `Bearer realm="mcp"`,
242
+ `resource_metadata="${metadataUrl(config)}"`,
243
+ ];
244
+ if (failure) {
245
+ parts.push(`error="${failure.error}"`, `error_description="${failure.description}"`);
246
+ }
247
+ return parts.join(', ');
248
+ }
249
+
250
+ /**
251
+ * Scopes arrive as a space-separated `scp` string (delegated tokens) or a
252
+ * `roles` array (application tokens); accept either.
253
+ */
254
+ function tokenScopes(payload: JWTPayload): string[] {
255
+ const scp = payload.scp;
256
+ if (typeof scp === 'string') {
257
+ return scp.split(' ').filter(Boolean);
258
+ }
259
+ if (Array.isArray(scp)) {
260
+ return scp.filter((s): s is string => typeof s === 'string');
261
+ }
262
+ const roles = payload.roles;
263
+ if (Array.isArray(roles)) {
264
+ return roles.filter((r): r is string => typeof r === 'string');
265
+ }
266
+ return [];
267
+ }
268
+
269
+ /**
270
+ * Verify the Authorization header of an incoming request.
271
+ *
272
+ * Returns a typed result rather than throwing so the caller can map it onto a
273
+ * 401 or 403 with the right WWW-Authenticate header.
274
+ */
275
+ export async function verifyBearer(
276
+ authorization: string | undefined,
277
+ config: AuthConfig
278
+ ): Promise<AuthResult> {
279
+ if (!authorization) {
280
+ return {
281
+ ok: false,
282
+ status: 401,
283
+ error: 'invalid_token',
284
+ description: 'Authorization header is required',
285
+ };
286
+ }
287
+
288
+ const match = /^Bearer[ ]+(.+)$/i.exec(authorization.trim());
289
+ if (!match) {
290
+ return {
291
+ ok: false,
292
+ status: 401,
293
+ error: 'invalid_token',
294
+ description: 'Authorization header must use the Bearer scheme',
295
+ };
296
+ }
297
+
298
+ let payload: JWTPayload;
299
+ try {
300
+ const verified = await jwtVerify(match[1], config.jwks, {
301
+ issuer: config.issuer,
302
+ audience: config.audience,
303
+ });
304
+ payload = verified.payload;
305
+ } catch (error) {
306
+ return {
307
+ ok: false,
308
+ status: 401,
309
+ error: 'invalid_token',
310
+ description: error instanceof Error ? error.message : 'Token verification failed',
311
+ };
312
+ }
313
+
314
+ if (config.requiredScopes) {
315
+ const granted = tokenScopes(payload);
316
+ if (!config.requiredScopes.some((scope) => granted.includes(scope))) {
317
+ return {
318
+ ok: false,
319
+ status: 403,
320
+ error: 'insufficient_scope',
321
+ description: `Token carries none of the required scopes: ${config.requiredScopes.join(', ')}`,
322
+ };
323
+ }
324
+ }
325
+
326
+ const caller = [payload.preferred_username, payload.upn, payload.appid]
327
+ .find((value): value is string => typeof value === 'string' && value.length > 0);
328
+
329
+ return { ok: true, subject: typeof payload.sub === 'string' ? payload.sub : undefined, caller };
330
+ }