mcp-server-bitbucket 0.11.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/settings.ts CHANGED
@@ -1,30 +1,65 @@
1
1
  /**
2
2
  * Settings management for Bitbucket MCP Server
3
- *
3
+ *
4
4
  * Configuration via environment variables:
5
5
  * - BITBUCKET_WORKSPACE: Bitbucket workspace slug (required)
6
- * - BITBUCKET_EMAIL: Account email for Basic Auth (required)
7
- * - BITBUCKET_API_TOKEN: Repository access token (required)
6
+ * - BITBUCKET_EMAIL: Account email for Basic Auth (required for basic auth)
7
+ * - BITBUCKET_API_TOKEN: Atlassian API token / access token (required)
8
+ * - BITBUCKET_OAUTH_TOKEN: Access token used with `Authorization: Bearer` (optional)
9
+ * - BITBUCKET_AUTH_TYPE: Force auth mode - 'basic' or 'bearer' (optional, auto-detected)
8
10
  * - API_TIMEOUT: Request timeout in seconds (default: 30, max: 300)
9
11
  * - MAX_RETRIES: Max retry attempts for rate limiting (default: 3, max: 10)
10
12
  * - OUTPUT_FORMAT: Output format - 'json' or 'toon' (default: json)
13
+ *
14
+ * Auth modes:
15
+ * - basic: Atlassian API tokens (ATATT...) authenticate as `email:token`.
16
+ * - bearer: Workspace/project/repository access tokens (ATCTT...) only work as
17
+ * `Authorization: Bearer <token>`; Basic auth returns 401 for them.
11
18
  */
12
19
 
13
20
  import { z } from 'zod';
14
21
 
22
+ export type AuthType = 'basic' | 'bearer';
23
+
15
24
  const settingsSchema = z.object({
16
25
  bitbucketWorkspace: z.string().min(1, 'BITBUCKET_WORKSPACE is required'),
17
- bitbucketEmail: z.string().min(1, 'BITBUCKET_EMAIL is required'),
18
- bitbucketApiToken: z.string().min(1, 'BITBUCKET_API_TOKEN is required'),
26
+ bitbucketEmail: z.string().default(''),
27
+ bitbucketApiToken: z.string().min(1, 'BITBUCKET_API_TOKEN (or BITBUCKET_OAUTH_TOKEN) is required'),
28
+ bitbucketAuthType: z.enum(['basic', 'bearer']),
19
29
  apiTimeout: z.number().min(1).max(300).default(30),
20
30
  maxRetries: z.number().min(0).max(10).default(3),
21
31
  outputFormat: z.enum(['json', 'toon']).default('json'),
22
- });
32
+ }).refine(
33
+ (s) => s.bitbucketAuthType !== 'basic' || s.bitbucketEmail.length > 0,
34
+ { message: 'BITBUCKET_EMAIL is required when using basic auth', path: ['bitbucketEmail'] }
35
+ );
23
36
 
24
37
  export type Settings = z.infer<typeof settingsSchema>;
25
38
 
26
39
  let cachedSettings: Settings | null = null;
27
40
 
41
+ /**
42
+ * Resolve which auth mode to use.
43
+ *
44
+ * Explicit BITBUCKET_AUTH_TYPE wins. Otherwise bearer is used when an
45
+ * OAuth/access token is configured or when no email is available (an access
46
+ * token has no account to pair with), and basic otherwise.
47
+ */
48
+ function resolveAuthType(
49
+ explicit: string,
50
+ oauthToken: string,
51
+ email: string
52
+ ): AuthType {
53
+ const normalized = explicit.trim().toLowerCase();
54
+ if (normalized === 'bearer' || normalized === 'basic') {
55
+ return normalized;
56
+ }
57
+ if (oauthToken.length > 0 || email.length === 0) {
58
+ return 'bearer';
59
+ }
60
+ return 'basic';
61
+ }
62
+
28
63
  /**
29
64
  * Load and validate settings from environment variables.
30
65
  * Results are cached for subsequent calls.
@@ -34,17 +69,24 @@ export function getSettings(): Settings {
34
69
  return cachedSettings;
35
70
  }
36
71
 
72
+ const email = process.env.BITBUCKET_EMAIL || '';
73
+ const oauthToken = process.env.BITBUCKET_OAUTH_TOKEN || '';
74
+ const apiToken = process.env.BITBUCKET_API_TOKEN || '';
75
+ const authType = resolveAuthType(process.env.BITBUCKET_AUTH_TYPE || '', oauthToken, email);
76
+
37
77
  const rawSettings = {
38
78
  bitbucketWorkspace: process.env.BITBUCKET_WORKSPACE || '',
39
- bitbucketEmail: process.env.BITBUCKET_EMAIL || '',
40
- bitbucketApiToken: process.env.BITBUCKET_API_TOKEN || '',
79
+ bitbucketEmail: email,
80
+ // Bearer mode prefers the dedicated OAuth/access token when both are set
81
+ bitbucketApiToken: authType === 'bearer' ? (oauthToken || apiToken) : apiToken,
82
+ bitbucketAuthType: authType,
41
83
  apiTimeout: parseInt(process.env.API_TIMEOUT || '30', 10),
42
84
  maxRetries: parseInt(process.env.MAX_RETRIES || '3', 10),
43
85
  outputFormat: (process.env.OUTPUT_FORMAT || 'json') as 'json' | 'toon',
44
86
  };
45
87
 
46
88
  const result = settingsSchema.safeParse(rawSettings);
47
-
89
+
48
90
  if (!result.success) {
49
91
  const errors = result.error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ');
50
92
  throw new Error(`Configuration error: ${errors}`);
@@ -60,4 +102,3 @@ export function getSettings(): Settings {
60
102
  export function resetSettings(): void {
61
103
  cachedSettings = null;
62
104
  }
63
-
@@ -10,12 +10,15 @@ import { CommitStatusState } from '../types.js';
10
10
  export const definitions: Tool[] = [
11
11
  {
12
12
  name: 'list_commits',
13
- description: 'List commits in a repository.',
13
+ 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.',
14
14
  inputSchema: {
15
15
  type: 'object',
16
16
  properties: {
17
17
  repo_slug: { type: 'string', description: 'Repository slug' },
18
- branch: { type: 'string', description: 'Filter by branch name (optional)' },
18
+ branch: { type: 'string', description: 'Branch, tag or SHA to include (alias for include)' },
19
+ include: { type: 'array', items: { type: 'string' }, description: 'Include commits reachable from these branches, tags or SHAs' },
20
+ exclude: { type: 'array', items: { type: 'string' }, description: 'Exclude commits reachable from these revisions' },
21
+ cursor: { type: 'string', description: 'Opaque next_cursor from this tool; retains its page size and filters' },
19
22
  path: { type: 'string', description: 'Filter by file path - only commits that modified this path (optional)' },
20
23
  limit: { type: 'number', description: 'Maximum results (default: 20)', default: 20 },
21
24
  },
@@ -36,17 +39,43 @@ export const definitions: Tool[] = [
36
39
  },
37
40
  {
38
41
  name: 'compare_commits',
39
- description: 'Compare two commits or branches and see files changed.',
42
+ description: 'Compare base to head (branches, tags or SHAs). Returns a page of changed files and line counts; follow next_cursor for all files.',
40
43
  inputSchema: {
41
44
  type: 'object',
42
45
  properties: {
43
46
  repo_slug: { type: 'string', description: 'Repository slug' },
44
47
  base: { type: 'string', description: 'Base commit hash or branch name' },
45
- head: { type: 'string', description: 'Head commit hash or branch name' },
48
+ head: { type: 'string', description: 'Head commit hash, tag or branch name' },
49
+ path: { type: 'string', description: 'Optional file path filter' },
50
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 50 },
51
+ cursor: { type: 'string', description: 'Opaque next_cursor; retains page size and filters' },
46
52
  },
47
53
  required: ['repo_slug', 'base', 'head'],
48
54
  },
49
55
  },
56
+ {
57
+ name: 'get_commit_diff',
58
+ 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.',
59
+ inputSchema: {
60
+ type: 'object',
61
+ properties: {
62
+ repo_slug: { type: 'string' }, base: { type: 'string' }, head: { type: 'string' },
63
+ path: { type: 'string', description: 'Optional file path filter' },
64
+ offset: { type: 'integer', minimum: 0, default: 0 },
65
+ max_chars: { type: 'integer', minimum: 1, maximum: 100000, default: 20000 },
66
+ },
67
+ required: ['repo_slug', 'base', 'head'],
68
+ },
69
+ },
70
+ {
71
+ name: 'get_merge_base',
72
+ description: 'Get a best common ancestor of two branches, tags or commit hashes. Bitbucket returns one ancestor when multiple best ancestors exist.',
73
+ inputSchema: {
74
+ type: 'object',
75
+ properties: { repo_slug: { type: 'string' }, base: { type: 'string' }, head: { type: 'string' } },
76
+ required: ['repo_slug', 'base', 'head'],
77
+ },
78
+ },
50
79
  {
51
80
  name: 'get_commit_statuses',
52
81
  description: 'Get build/CI statuses for a commit.',
@@ -82,18 +111,18 @@ export const definitions: Tool[] = [
82
111
  export const handlers: Record<string, (args: Record<string, unknown>) => Promise<Record<string, unknown>>> = {
83
112
  list_commits: async (args) => {
84
113
  const client = getClient();
85
- const commits = await client.listCommits(args.repo_slug as string, {
114
+ const page = await client.listCommitsPage(args.repo_slug as string, {
86
115
  branch: args.branch as string,
116
+ include: args.include as string[] | undefined,
117
+ exclude: args.exclude as string[] | undefined,
87
118
  path: args.path as string,
88
- limit: validateLimit((args.limit as number) || 20),
119
+ cursor: args.cursor as string,
120
+ limit: validateLimit((args.limit as number) ?? 20),
89
121
  });
90
122
  return {
91
- commits: commits.map(c => ({
92
- hash: truncateHash(c.hash),
93
- message: c.message,
94
- author: c.author?.raw,
95
- date: c.date,
96
- })),
123
+ commits: page.values.map(c => ({ hash: truncateHash(c.hash), message: c.message, author: c.author?.raw, date: c.date })),
124
+ has_more: page.has_more,
125
+ next_cursor: page.next_cursor,
97
126
  };
98
127
  },
99
128
 
@@ -114,31 +143,34 @@ export const handlers: Record<string, (args: Record<string, unknown>) => Promise
114
143
 
115
144
  compare_commits: async (args) => {
116
145
  const client = getClient();
117
- const result = await client.compareCommits(
118
- args.repo_slug as string,
119
- args.base as string,
120
- args.head as string
121
- );
122
- if (!result) {
123
- return { error: `Could not compare ${args.base}..${args.head}` };
124
- }
125
- const files = (result.values as Array<{
126
- new?: { path?: string };
127
- old?: { path?: string };
128
- status?: string;
129
- lines_added?: number;
130
- lines_removed?: number;
131
- }>) || [];
146
+ const page = await client.compareCommitsPage(args.repo_slug as string, args.base as string, args.head as string, {
147
+ path: args.path as string, limit: validateLimit((args.limit as number) ?? 50), cursor: args.cursor as string,
148
+ });
149
+ const files = page.values as Array<{ new?: { path?: string }; old?: { path?: string }; status?: string; lines_added?: number; lines_removed?: number }>;
132
150
  return {
133
- files: files.slice(0, 50).map(f => ({
134
- path: f.new?.path || f.old?.path,
135
- status: f.status,
136
- '+': f.lines_added || 0,
137
- '-': f.lines_removed || 0,
138
- })),
151
+ files: files.map(f => ({ path: f.new?.path || f.old?.path, status: f.status, '+': f.lines_added || 0, '-': f.lines_removed || 0 })),
152
+ has_more: page.has_more, next_cursor: page.next_cursor,
139
153
  };
140
154
  },
141
155
 
156
+ get_commit_diff: async (args) => {
157
+ const offset = args.offset === undefined ? 0 : Number(args.offset);
158
+ const maxChars = args.max_chars === undefined ? 20000 : Number(args.max_chars);
159
+ if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(maxChars) || maxChars < 1 || maxChars > 100000) {
160
+ return { success: false, error: 'offset must be a nonnegative integer; max_chars must be an integer between 1 and 100000' };
161
+ }
162
+ const diff = await getClient().getCommitDiff(args.repo_slug as string, args.base as string, args.head as string, { path: args.path as string });
163
+ if (diff === null) return notFoundResponse('Diff', `${args.base}..${args.head}`);
164
+ const end = Math.min(offset + maxChars, diff.length);
165
+ return { diff: diff.slice(offset, end), offset, next_offset: end < diff.length ? end : null, truncated: end < diff.length, total_chars: diff.length };
166
+ },
167
+
168
+ get_merge_base: async (args) => {
169
+ const result = await getClient().getMergeBase(args.repo_slug as string, args.base as string, args.head as string);
170
+ return result ? { hash: result.hash, message: result.message, author: result.author?.raw, date: result.date, parents: result.parents?.map(p => truncateHash(p.hash)) }
171
+ : notFoundResponse('Common ancestor', `${args.base}..${args.head}`);
172
+ },
173
+
142
174
  get_commit_statuses: async (args) => {
143
175
  const client = getClient();
144
176
  const statuses = await client.getCommitStatuses(args.repo_slug as string, args.commit as string, {
@@ -3,9 +3,6 @@
3
3
  */
4
4
 
5
5
  import { Tool } from '@modelcontextprotocol/sdk/types.js';
6
- import { getClient } from '../client.js';
7
- import { validateLimit, notFoundResponse, truncateHash, sanitizeSearchTerm } from '../utils.js';
8
- import { PRState, MergeStrategy, CommitStatusState } from '../types.js';
9
6
 
10
7
  // Import tool handlers from separate files
11
8
  import * as repositoryTools from './repositories.js';
@@ -146,6 +146,29 @@ export const definitions: Tool[] = [
146
146
  required: ['repo_slug', 'variable_uuid'],
147
147
  },
148
148
  },
149
+ {
150
+ name: 'get_pipeline_config',
151
+ description: 'Get pipeline configuration for a repository (check if pipelines are enabled).',
152
+ inputSchema: {
153
+ type: 'object',
154
+ properties: {
155
+ repo_slug: { type: 'string', description: 'Repository slug' },
156
+ },
157
+ required: ['repo_slug'],
158
+ },
159
+ },
160
+ {
161
+ name: 'update_pipeline_config',
162
+ description: 'Update pipeline configuration for a repository (enable or disable pipelines).',
163
+ inputSchema: {
164
+ type: 'object',
165
+ properties: {
166
+ repo_slug: { type: 'string', description: 'Repository slug' },
167
+ enabled: { type: 'boolean', description: 'Enable or disable pipelines' },
168
+ },
169
+ required: ['repo_slug', 'enabled'],
170
+ },
171
+ },
149
172
  ];
150
173
 
151
174
  export const handlers: Record<string, (args: Record<string, unknown>) => Promise<Record<string, unknown>>> = {
@@ -297,5 +320,23 @@ export const handlers: Record<string, (args: Record<string, unknown>) => Promise
297
320
  await client.deletePipelineVariable(args.repo_slug as string, args.variable_uuid as string);
298
321
  return {};
299
322
  },
323
+
324
+ get_pipeline_config: async (args) => {
325
+ const client = getClient();
326
+ const result = await client.getPipelineConfig(args.repo_slug as string);
327
+ return {
328
+ enabled: result.enabled,
329
+ };
330
+ },
331
+
332
+ update_pipeline_config: async (args) => {
333
+ const client = getClient();
334
+ const result = await client.updatePipelineConfig(args.repo_slug as string, {
335
+ enabled: args.enabled as boolean,
336
+ });
337
+ return {
338
+ enabled: result.enabled,
339
+ };
340
+ },
300
341
  };
301
342
 
@@ -20,6 +20,19 @@ export const definitions: Tool[] = [
20
20
  required: ['repo_slug', 'path'],
21
21
  },
22
22
  },
23
+ {
24
+ name: 'get_file_history',
25
+ 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.',
26
+ inputSchema: {
27
+ type: 'object',
28
+ properties: {
29
+ repo_slug: { type: 'string' }, path: { type: 'string' }, ref: { type: 'string', default: 'main' },
30
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
31
+ cursor: { type: 'string', description: 'Opaque next_cursor; retains page size and filters' },
32
+ },
33
+ required: ['repo_slug', 'path'],
34
+ },
35
+ },
23
36
  {
24
37
  name: 'list_directory',
25
38
  description: 'List contents of a directory in a repository. Browse repository structure without cloning.',
@@ -54,6 +67,16 @@ export const handlers: Record<string, (args: Record<string, unknown>) => Promise
54
67
  };
55
68
  },
56
69
 
70
+ get_file_history: async (args) => {
71
+ const page = await getClient().getFileHistory(args.repo_slug as string, args.path as string, (args.ref as string) || 'main', {
72
+ limit: validateLimit((args.limit as number) ?? 20), cursor: args.cursor as string,
73
+ });
74
+ return {
75
+ history: page.values.map(entry => ({ path: entry.path, commit: { hash: entry.commit.hash } })),
76
+ has_more: page.has_more, next_cursor: page.next_cursor,
77
+ };
78
+ },
79
+
57
80
  list_directory: async (args) => {
58
81
  const client = getClient();
59
82
  const ref = (args.ref as string) || 'main';
package/src/tools/tags.ts CHANGED
@@ -4,21 +4,29 @@
4
4
 
5
5
  import { Tool } from '@modelcontextprotocol/sdk/types.js';
6
6
  import { getClient } from '../client.js';
7
- import { validateLimit, truncateHash } from '../utils.js';
7
+ import { validateLimit, truncateHash, notFoundResponse } from '../utils.js';
8
8
 
9
9
  export const definitions: Tool[] = [
10
10
  {
11
11
  name: 'list_tags',
12
- description: 'List tags in a repository.',
12
+ 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.',
13
13
  inputSchema: {
14
14
  type: 'object',
15
15
  properties: {
16
16
  repo_slug: { type: 'string', description: 'Repository slug' },
17
- limit: { type: 'number', description: 'Maximum results (default: 50)', default: 50 },
17
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 50 },
18
+ query: { type: 'string', description: 'Bitbucket filter, e.g. name ~ \"v2\" or target.hash = \"full-sha\"' },
19
+ sort: { type: 'string', description: 'Sort field, e.g. name, -name, -target.date', default: '-target.date' },
20
+ cursor: { type: 'string', description: 'Opaque next_cursor; retains page size and filters' },
18
21
  },
19
22
  required: ['repo_slug'],
20
23
  },
21
24
  },
25
+ {
26
+ name: 'get_tag',
27
+ description: 'Get a specific tag and its target commit without scanning tag pages.',
28
+ inputSchema: { type: 'object', properties: { repo_slug: { type: 'string' }, tag_name: { type: 'string' } }, required: ['repo_slug', 'tag_name'] },
29
+ },
22
30
  {
23
31
  name: 'create_tag',
24
32
  description: 'Create a new tag in a repository.',
@@ -50,20 +58,27 @@ export const definitions: Tool[] = [
50
58
  export const handlers: Record<string, (args: Record<string, unknown>) => Promise<Record<string, unknown>>> = {
51
59
  list_tags: async (args) => {
52
60
  const client = getClient();
53
- const tags = await client.listTags(args.repo_slug as string, {
54
- limit: validateLimit((args.limit as number) || 50),
61
+ const page = await client.listTagsPage(args.repo_slug as string, {
62
+ limit: validateLimit((args.limit as number) ?? 50),
63
+ query: args.query as string, sort: args.sort as string, cursor: args.cursor as string,
55
64
  });
56
65
  return {
57
- tags: tags.map(t => ({
66
+ tags: page.values.map(t => ({
58
67
  name: t.name,
59
68
  target: truncateHash(t.target?.hash),
60
69
  message: t.message,
61
70
  date: t.target?.date,
62
71
  tagger: t.tagger?.raw,
63
72
  })),
73
+ has_more: page.has_more, next_cursor: page.next_cursor,
64
74
  };
65
75
  },
66
76
 
77
+ get_tag: async (args) => {
78
+ const tag = await getClient().getTag(args.repo_slug as string, args.tag_name as string);
79
+ return tag ? { name: tag.name, target: tag.target?.hash, message: tag.message, date: tag.date, tagger: tag.tagger?.raw } : notFoundResponse('Tag', args.tag_name as string);
80
+ },
81
+
67
82
  create_tag: async (args) => {
68
83
  const client = getClient();
69
84
  const result = await client.createTag(
package/src/types.ts CHANGED
@@ -144,6 +144,15 @@ export interface BitbucketPipelineVariable {
144
144
  secured: boolean;
145
145
  }
146
146
 
147
+ export interface BitbucketPipelineConfig {
148
+ enabled: boolean;
149
+ type?: string;
150
+ repository?: {
151
+ uuid?: string;
152
+ full_name?: string;
153
+ };
154
+ }
155
+
147
156
  // ==================== PIPELINE TRIGGER OPTIONS ====================
148
157
 
149
158
  export interface PipelineTriggerVariable {