This is page 1 of 3. Use http://codebase.md/aashari/mcp-server-atlassian-bitbucket?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .env.example
├── .github
│ ├── dependabot.yml
│ └── workflows
│ ├── ci-dependabot-auto-merge.yml
│ ├── ci-dependency-check.yml
│ └── ci-semantic-release.yml
├── .gitignore
├── .gitkeep
├── .npmignore
├── .npmrc
├── .prettierrc
├── .releaserc.json
├── .trigger-ci
├── CHANGELOG.md
├── eslint.config.mjs
├── package-lock.json
├── package.json
├── README.md
├── scripts
│ ├── ensure-executable.js
│ ├── package.json
│ └── update-version.js
├── src
│ ├── cli
│ │ ├── atlassian.api.cli.ts
│ │ ├── atlassian.repositories.cli.ts
│ │ └── index.ts
│ ├── controllers
│ │ ├── atlassian.api.controller.ts
│ │ └── atlassian.repositories.content.controller.ts
│ ├── index.ts
│ ├── services
│ │ ├── vendor.atlassian.repositories.service.test.ts
│ │ ├── vendor.atlassian.repositories.service.ts
│ │ ├── vendor.atlassian.repositories.types.ts
│ │ ├── vendor.atlassian.workspaces.service.ts
│ │ ├── vendor.atlassian.workspaces.test.ts
│ │ └── vendor.atlassian.workspaces.types.ts
│ ├── tools
│ │ ├── atlassian.api.tool.ts
│ │ ├── atlassian.api.types.ts
│ │ ├── atlassian.repositories.tool.ts
│ │ └── atlassian.repositories.types.ts
│ ├── types
│ │ └── common.types.ts
│ └── utils
│ ├── bitbucket-error-detection.test.ts
│ ├── cli.test.util.ts
│ ├── config.util.test.ts
│ ├── config.util.ts
│ ├── constants.util.ts
│ ├── error-handler.util.test.ts
│ ├── error-handler.util.ts
│ ├── error.util.test.ts
│ ├── error.util.ts
│ ├── formatter.util.ts
│ ├── jest.setup.ts
│ ├── jq.util.ts
│ ├── logger.util.ts
│ ├── pagination.util.ts
│ ├── response.util.ts
│ ├── shell.util.ts
│ ├── toon.util.test.ts
│ ├── toon.util.ts
│ ├── transport.util.test.ts
│ ├── transport.util.ts
│ └── workspace.util.ts
├── STYLE_GUIDE.md
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/.gitkeep:
--------------------------------------------------------------------------------
```
1 |
```
--------------------------------------------------------------------------------
/.trigger-ci:
--------------------------------------------------------------------------------
```
1 | # CI/CD trigger Thu Sep 18 00:41:08 WIB 2025
2 |
```
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
```
1 | {
2 | "singleQuote": true,
3 | "semi": true,
4 | "useTabs": true,
5 | "tabWidth": 4,
6 | "printWidth": 80,
7 | "trailingComma": "all"
8 | }
```
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
```
1 | # This file is for local development only
2 | # The CI/CD workflow will create its own .npmrc files
3 |
4 | # For npm registry
5 | registry=https://registry.npmjs.org/
6 |
7 | # GitHub Packages configuration removed
8 |
```
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
```
1 | # Source code
2 | src/
3 | *.ts
4 | !*.d.ts
5 |
6 | # Tests
7 | *.test.ts
8 | *.test.js
9 | __tests__/
10 | coverage/
11 | jest.config.js
12 |
13 | # Development files
14 | .github/
15 | .git/
16 | .gitignore
17 | .eslintrc
18 | .eslintrc.js
19 | .eslintignore
20 | .prettierrc
21 | .prettierrc.js
22 | tsconfig.json
23 | *.tsbuildinfo
24 |
25 | # Editor directories
26 | .idea/
27 | .vscode/
28 | *.swp
29 | *.swo
30 |
31 | # Logs
32 | logs
33 | *.log
34 | npm-debug.log*
35 | yarn-debug.log*
36 | yarn-error.log*
37 |
38 | # CI/CD
39 | .travis.yml
40 |
41 | # Runtime data
42 | .env
43 | .env.*
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | # Dependency directories
2 | node_modules/
3 | .npm
4 |
5 | # TypeScript output
6 | dist/
7 | build/
8 | *.tsbuildinfo
9 |
10 | # Coverage directories
11 | coverage/
12 | .nyc_output/
13 |
14 | # Environment variables
15 | .env
16 | .env.local
17 | .env.*.local
18 |
19 | # Log files
20 | logs/
21 | *.log
22 | npm-debug.log*
23 | yarn-debug.log*
24 | yarn-error.log*
25 |
26 | # IDE files
27 | .idea/
28 | .vscode/
29 | *.sublime-project
30 | *.sublime-workspace
31 | .project
32 | .classpath
33 | .settings/
34 | .DS_Store
35 |
36 | # Temp directories
37 | .tmp/
38 | temp/
39 |
40 | # Backup files
41 | *.bak
42 |
43 | # Editor directories and files
44 | *.suo
45 | *.ntvs*
46 | *.njsproj
47 | *.sln
48 | *.sw?
49 |
50 | # macOS
51 | .DS_Store
52 |
53 | # Misc
54 | .yarn-integrity
55 |
```
--------------------------------------------------------------------------------
/.releaserc.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "branches": ["main"],
3 | "plugins": [
4 | "@semantic-release/commit-analyzer",
5 | "@semantic-release/release-notes-generator",
6 | "@semantic-release/changelog",
7 | [
8 | "@semantic-release/exec",
9 | {
10 | "prepareCmd": "node scripts/update-version.js ${nextRelease.version} && npm run build && chmod +x dist/index.js"
11 | }
12 | ],
13 | [
14 | "@semantic-release/npm",
15 | {
16 | "npmPublish": true,
17 | "pkgRoot": "."
18 | }
19 | ],
20 | [
21 | "@semantic-release/git",
22 | {
23 | "assets": [
24 | "package.json",
25 | "CHANGELOG.md",
26 | "src/index.ts",
27 | "src/cli/index.ts"
28 | ],
29 | "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
30 | }
31 | ],
32 | "@semantic-release/github"
33 | ]
34 | }
35 |
```
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
```
1 | # Enable debug logging (optional)
2 | DEBUG=false
3 |
4 | # ============================================================================
5 | # AUTHENTICATION - Choose ONE method below
6 | # ============================================================================
7 |
8 | # Method 1: Scoped API Token (RECOMMENDED - future-proof)
9 | # Generate at: https://id.atlassian.com/manage-profile/security/api-tokens
10 | # Token should start with "ATATT"
11 | # Required scopes: repository, workspace, pullrequest (for PR management)
12 | [email protected]
13 | ATLASSIAN_API_TOKEN=your_scoped_api_token_starting_with_ATATT
14 |
15 | # Method 2: Bitbucket App Password (LEGACY - deprecated June 2026)
16 | # Generate at: https://bitbucket.org/account/settings/app-passwords/
17 | # Required permissions: Workspaces (Read), Repositories (Read/Write), Pull Requests (Read/Write)
18 | # ATLASSIAN_BITBUCKET_USERNAME=your-bitbucket-username
19 | # ATLASSIAN_BITBUCKET_APP_PASSWORD=your-app-password
20 |
21 | # ============================================================================
22 | # OPTIONAL CONFIGURATION
23 | # ============================================================================
24 |
25 | # Default workspace for commands (optional - uses first workspace if not set)
26 | # BITBUCKET_DEFAULT_WORKSPACE=your-main-workspace-slug
27 |
28 | # Note: ATLASSIAN_SITE_NAME is NOT needed for Bitbucket Cloud
29 | # Only use it if you're configuring Jira/Confluence alongside Bitbucket
30 |
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # Connect AI to Your Bitbucket Repositories
2 |
3 | Transform how you work with Bitbucket by connecting Claude, Cursor AI, and other AI assistants directly to your repositories, pull requests, and code. Get instant insights, automate code reviews, and streamline your development workflow.
4 |
5 | [](https://www.npmjs.com/package/@aashari/mcp-server-atlassian-bitbucket)
6 | [](https://github.com/aashari/mcp-server-atlassian-bitbucket/blob/main/LICENSE)
7 |
8 | **Current Version:** 2.2.0
9 |
10 | ## What You Can Do
11 |
12 | - **Ask AI about your code**: "What's the latest commit in my main repository?"
13 | - **Get PR insights**: "Show me all open pull requests that need review"
14 | - **Search your codebase**: "Find all JavaScript files that use the authentication function"
15 | - **Review code changes**: "Compare the differences between my feature branch and main"
16 | - **Manage pull requests**: "Create a PR for my new-feature branch"
17 | - **Automate workflows**: "Add a comment to PR #123 with the test results"
18 |
19 | ## Perfect For
20 |
21 | - **Developers** who want AI assistance with code reviews and repository management
22 | - **Team Leads** needing quick insights into project status and pull request activity
23 | - **DevOps Engineers** automating repository workflows and branch management
24 | - **Anyone** who wants to interact with Bitbucket using natural language
25 |
26 | ## Requirements
27 |
28 | - **Node.js** 18.0.0 or higher
29 | - **Bitbucket Cloud** account (not Bitbucket Server/Data Center)
30 | - **Authentication credentials**: Scoped API Token (recommended) or App Password (legacy)
31 |
32 | ## Quick Start
33 |
34 | Get up and running in 2 minutes:
35 |
36 | ### 1. Get Your Bitbucket Credentials
37 |
38 | > **IMPORTANT**: Bitbucket App Passwords are being deprecated and will be removed by **June 2026**. We recommend using **Scoped API Tokens** for new setups.
39 |
40 | #### Option A: Scoped API Token (Recommended - Future-Proof)
41 |
42 | **Bitbucket is deprecating app passwords**. Use the new scoped API tokens instead:
43 |
44 | 1. Go to [Atlassian API Tokens](https://id.atlassian.com/manage-profile/security/api-tokens)
45 | 2. Click **"Create API token with scopes"**
46 | 3. Select **"Bitbucket"** as the product
47 | 4. Choose the appropriate scopes:
48 | - **For read-only access**: `repository`, `workspace`
49 | - **For full functionality**: `repository`, `workspace`, `pullrequest`
50 | 5. Copy the generated token (starts with `ATATT`)
51 | 6. Use with your Atlassian email as the username
52 |
53 | #### Option B: App Password (Legacy - Will be deprecated)
54 |
55 | Generate a Bitbucket App Password (legacy method):
56 | 1. Go to [Bitbucket App Passwords](https://bitbucket.org/account/settings/app-passwords/)
57 | 2. Click "Create app password"
58 | 3. Give it a name like "AI Assistant"
59 | 4. Select these permissions:
60 | - **Workspaces**: Read
61 | - **Repositories**: Read (and Write if you want AI to create PRs/comments)
62 | - **Pull Requests**: Read (and Write for PR management)
63 |
64 | ### 2. Try It Instantly
65 |
66 | ```bash
67 | # Set your credentials (choose one method)
68 |
69 | # Method 1: Scoped API Token (recommended - future-proof)
70 | export ATLASSIAN_USER_EMAIL="[email protected]"
71 | export ATLASSIAN_API_TOKEN="your_scoped_api_token" # Token starting with ATATT
72 |
73 | # OR Method 2: Legacy App Password (will be deprecated June 2026)
74 | export ATLASSIAN_BITBUCKET_USERNAME="your_username"
75 | export ATLASSIAN_BITBUCKET_APP_PASSWORD="your_app_password"
76 |
77 | # List your workspaces
78 | npx -y @aashari/mcp-server-atlassian-bitbucket get --path "/workspaces"
79 |
80 | # List repositories in a workspace
81 | npx -y @aashari/mcp-server-atlassian-bitbucket get --path "/repositories/your-workspace"
82 |
83 | # Get pull requests for a repository
84 | npx -y @aashari/mcp-server-atlassian-bitbucket get --path "/repositories/your-workspace/your-repo/pullrequests"
85 |
86 | # Get repository details with JMESPath filtering
87 | npx -y @aashari/mcp-server-atlassian-bitbucket get --path "/repositories/your-workspace/your-repo" --jq "{name: name, language: language}"
88 | ```
89 |
90 | ## Connect to AI Assistants
91 |
92 | ### For Claude Desktop Users
93 |
94 | Add this to your Claude configuration file (`~/.claude/claude_desktop_config.json`):
95 |
96 | **Option 1: Scoped API Token (recommended - future-proof)**
97 | ```json
98 | {
99 | "mcpServers": {
100 | "bitbucket": {
101 | "command": "npx",
102 | "args": ["-y", "@aashari/mcp-server-atlassian-bitbucket"],
103 | "env": {
104 | "ATLASSIAN_USER_EMAIL": "[email protected]",
105 | "ATLASSIAN_API_TOKEN": "your_scoped_api_token"
106 | }
107 | }
108 | }
109 | }
110 | ```
111 |
112 | **Option 2: Legacy App Password (will be deprecated June 2026)**
113 | ```json
114 | {
115 | "mcpServers": {
116 | "bitbucket": {
117 | "command": "npx",
118 | "args": ["-y", "@aashari/mcp-server-atlassian-bitbucket"],
119 | "env": {
120 | "ATLASSIAN_BITBUCKET_USERNAME": "your_username",
121 | "ATLASSIAN_BITBUCKET_APP_PASSWORD": "your_app_password"
122 | }
123 | }
124 | }
125 | }
126 | ```
127 |
128 | Restart Claude Desktop, and you'll see the bitbucket server in the status bar.
129 |
130 | ### For Other AI Assistants
131 |
132 | Most AI assistants support MCP. You can either:
133 |
134 | **Option 1: Use npx (recommended - always latest version):**
135 | Configure your AI assistant to run: `npx -y @aashari/mcp-server-atlassian-bitbucket`
136 |
137 | **Option 2: Install globally:**
138 | ```bash
139 | npm install -g @aashari/mcp-server-atlassian-bitbucket
140 | ```
141 |
142 | Then configure your AI assistant to use the MCP server with STDIO transport.
143 |
144 | **Supported AI assistants:**
145 | - Claude Desktop (official support)
146 | - Cursor AI
147 | - Continue.dev
148 | - Cline
149 | - Any MCP-compatible client
150 |
151 | ### Alternative: Configuration File
152 |
153 | Create `~/.mcp/configs.json` for system-wide configuration:
154 |
155 | **Option 1: Scoped API Token (recommended - future-proof)**
156 | ```json
157 | {
158 | "bitbucket": {
159 | "environments": {
160 | "ATLASSIAN_USER_EMAIL": "[email protected]",
161 | "ATLASSIAN_API_TOKEN": "your_scoped_api_token",
162 | "BITBUCKET_DEFAULT_WORKSPACE": "your_main_workspace"
163 | }
164 | }
165 | }
166 | ```
167 |
168 | **Option 2: Legacy App Password (will be deprecated June 2026)**
169 | ```json
170 | {
171 | "bitbucket": {
172 | "environments": {
173 | "ATLASSIAN_BITBUCKET_USERNAME": "your_username",
174 | "ATLASSIAN_BITBUCKET_APP_PASSWORD": "your_app_password",
175 | "BITBUCKET_DEFAULT_WORKSPACE": "your_main_workspace"
176 | }
177 | }
178 | }
179 | ```
180 |
181 | **Alternative config keys:** The system also accepts `"atlassian-bitbucket"`, `"@aashari/mcp-server-atlassian-bitbucket"`, or `"mcp-server-atlassian-bitbucket"` instead of `"bitbucket"`.
182 |
183 | ## Available Tools
184 |
185 | This MCP server provides 6 generic tools that can access any Bitbucket API endpoint:
186 |
187 | | Tool | Description | Parameters |
188 | |------|-------------|------------|
189 | | `bb_get` | GET any Bitbucket API endpoint (read data) | `path`, `queryParams?`, `jq?`, `outputFormat?` |
190 | | `bb_post` | POST to any endpoint (create resources) | `path`, `body`, `queryParams?`, `jq?`, `outputFormat?` |
191 | | `bb_put` | PUT to any endpoint (replace resources) | `path`, `body`, `queryParams?`, `jq?`, `outputFormat?` |
192 | | `bb_patch` | PATCH any endpoint (partial updates) | `path`, `body`, `queryParams?`, `jq?`, `outputFormat?` |
193 | | `bb_delete` | DELETE any endpoint (remove resources) | `path`, `queryParams?`, `jq?`, `outputFormat?` |
194 | | `bb_clone` | Clone a repository locally | `workspaceSlug?`, `repoSlug`, `targetPath` |
195 |
196 | ### Tool Parameters
197 |
198 | All API tools support these common parameters:
199 |
200 | - **`path`** (required): API endpoint path starting with `/` (the `/2.0` prefix is added automatically)
201 | - **`queryParams`** (optional): Key-value pairs for query parameters (e.g., `{"pagelen": "25", "page": "2"}`)
202 | - **`jq`** (optional): JMESPath expression to filter/transform the response - **highly recommended** to reduce token costs
203 | - **`outputFormat`** (optional): `"toon"` (default, 30-60% fewer tokens) or `"json"`
204 | - **`body`** (required for POST/PUT/PATCH): Request body as JSON object
205 |
206 | ### Common API Paths
207 |
208 | All paths automatically have `/2.0` prepended. Full Bitbucket Cloud REST API 2.0 reference: https://developer.atlassian.com/cloud/bitbucket/rest/
209 |
210 | **Workspaces & Repositories:**
211 | - `/workspaces` - List all workspaces
212 | - `/repositories/{workspace}` - List repos in workspace
213 | - `/repositories/{workspace}/{repo}` - Get repo details
214 | - `/repositories/{workspace}/{repo}/refs/branches` - List branches
215 | - `/repositories/{workspace}/{repo}/refs/branches/{branch_name}` - Get/delete branch
216 | - `/repositories/{workspace}/{repo}/commits` - List commits
217 | - `/repositories/{workspace}/{repo}/commits/{commit}` - Get commit details
218 | - `/repositories/{workspace}/{repo}/src/{commit}/{filepath}` - Get file content
219 |
220 | **Pull Requests:**
221 | - `/repositories/{workspace}/{repo}/pullrequests` - List PRs (GET) or create PR (POST)
222 | - `/repositories/{workspace}/{repo}/pullrequests/{id}` - Get/update/delete PR
223 | - `/repositories/{workspace}/{repo}/pullrequests/{id}/diff` - Get PR diff
224 | - `/repositories/{workspace}/{repo}/pullrequests/{id}/comments` - List/add PR comments
225 | - `/repositories/{workspace}/{repo}/pullrequests/{id}/approve` - Approve PR (POST) or remove approval (DELETE)
226 | - `/repositories/{workspace}/{repo}/pullrequests/{id}/request-changes` - Request changes (POST)
227 | - `/repositories/{workspace}/{repo}/pullrequests/{id}/merge` - Merge PR (POST)
228 | - `/repositories/{workspace}/{repo}/pullrequests/{id}/decline` - Decline PR (POST)
229 |
230 | **Comparisons:**
231 | - `/repositories/{workspace}/{repo}/diff/{source}..{destination}` - Compare branches/commits
232 |
233 | **Other Resources:**
234 | - `/repositories/{workspace}/{repo}/issues` - List/manage issues
235 | - `/repositories/{workspace}/{repo}/downloads` - List/manage downloads
236 | - `/repositories/{workspace}/{repo}/pipelines` - Access Bitbucket Pipelines
237 | - `/repositories/{workspace}/{repo}/deployments` - View deployments
238 |
239 | ### TOON Output Format
240 |
241 | **What is TOON?** Token-Oriented Object Notation is a format optimized for LLMs that reduces token consumption by 30-60% compared to JSON. It uses tabular arrays and minimal syntax while preserving all data.
242 |
243 | **Default behavior:** All tools return TOON format by default. You can override this with `outputFormat: "json"` if needed.
244 |
245 | **Example comparison:**
246 | ```
247 | JSON (verbose):
248 | {
249 | "values": [
250 | {"name": "repo1", "slug": "repo-1"},
251 | {"name": "repo2", "slug": "repo-2"}
252 | ]
253 | }
254 |
255 | TOON (efficient):
256 | values:
257 | name | slug
258 | repo1 | repo-1
259 | repo2 | repo-2
260 | ```
261 |
262 | Learn more: https://github.com/toon-format/toon
263 |
264 | ### JMESPath Filtering
265 |
266 | All tools support optional JMESPath (`jq`) filtering to extract specific data and reduce token costs further:
267 |
268 | **Important:** Always use `jq` to filter responses! Unfiltered API responses can be very large and expensive in terms of tokens.
269 |
270 | ```bash
271 | # Get just repository names
272 | npx -y @aashari/mcp-server-atlassian-bitbucket get \
273 | --path "/repositories/myworkspace" \
274 | --jq "values[].name"
275 |
276 | # Get PR titles and states (custom object shape)
277 | npx -y @aashari/mcp-server-atlassian-bitbucket get \
278 | --path "/repositories/myworkspace/myrepo/pullrequests" \
279 | --jq "values[].{title: title, state: state, author: author.display_name}"
280 |
281 | # Get first result only
282 | npx -y @aashari/mcp-server-atlassian-bitbucket get \
283 | --path "/repositories/myworkspace" \
284 | --jq "values[0]"
285 |
286 | # Explore schema with one item first, then filter
287 | npx -y @aashari/mcp-server-atlassian-bitbucket get \
288 | --path "/workspaces" \
289 | --query-params '{"pagelen": "1"}'
290 | ```
291 |
292 | **Common JMESPath patterns:**
293 | - `values[*].fieldName` - Extract single field from all items
294 | - `values[*].{key1: field1, key2: field2}` - Create custom object shape
295 | - `values[0]` - Get first item only
296 | - `values[:5]` - Get first 5 items
297 | - `values[?state=='OPEN']` - Filter by condition
298 |
299 | Full JMESPath reference: https://jmespath.org
300 |
301 | ## Real-World Examples
302 |
303 | ### Explore Your Repositories
304 |
305 | Ask your AI assistant:
306 | - *"List all repositories in my main workspace"*
307 | - *"Show me details about the backend-api repository"*
308 | - *"What's the commit history for the feature-auth branch?"*
309 | - *"Get the content of src/config.js from the main branch"*
310 |
311 | ### Manage Pull Requests
312 |
313 | Ask your AI assistant:
314 | - *"Show me all open pull requests that need review"*
315 | - *"Get details about pull request #42 including the code changes"*
316 | - *"Create a pull request from feature-login to main branch"*
317 | - *"Add a comment to PR #15 saying the tests passed"*
318 | - *"Approve pull request #33"*
319 |
320 | ### Work with Branches and Code
321 |
322 | Ask your AI assistant:
323 | - *"Compare my feature branch with the main branch"*
324 | - *"List all branches in the user-service repository"*
325 | - *"Show me the differences between commits abc123 and def456"*
326 |
327 | ## Advanced Usage
328 |
329 | ### Cost Optimization Tips
330 |
331 | 1. **Always use JMESPath filtering** - Extract only needed fields to minimize token usage
332 | 2. **Use pagination wisely** - Set `pagelen` query parameter to limit results (e.g., `{"pagelen": "10"}`)
333 | 3. **Explore schema first** - Fetch one item without filters to see available fields, then filter subsequent calls
334 | 4. **Leverage TOON format** - Default TOON format saves 30-60% tokens vs JSON
335 | 5. **Query parameters for filtering** - Use Bitbucket's `q` parameter for server-side filtering before results are returned
336 |
337 | ### Query Parameter Examples
338 |
339 | ```bash
340 | # Filter PRs by state
341 | npx -y @aashari/mcp-server-atlassian-bitbucket get \
342 | --path "/repositories/workspace/repo/pullrequests" \
343 | --query-params '{"state": "OPEN", "pagelen": "5"}' \
344 | --jq "values[*].{id: id, title: title}"
345 |
346 | # Search PRs by title
347 | npx -y @aashari/mcp-server-atlassian-bitbucket get \
348 | --path "/repositories/workspace/repo/pullrequests" \
349 | --query-params '{"q": "title~\"bug\""}' \
350 | --jq "values[*].{id: id, title: title}"
351 |
352 | # Filter repositories by role
353 | npx -y @aashari/mcp-server-atlassian-bitbucket get \
354 | --path "/repositories/workspace" \
355 | --query-params '{"role": "owner", "pagelen": "10"}'
356 |
357 | # Sort by updated date
358 | npx -y @aashari/mcp-server-atlassian-bitbucket get \
359 | --path "/repositories/workspace/repo/pullrequests" \
360 | --query-params '{"sort": "-updated_on"}' \
361 | --jq "values[*].{id: id, title: title, updated: updated_on}"
362 | ```
363 |
364 | ### Working with Large Responses
365 |
366 | When dealing with APIs that return large payloads:
367 |
368 | 1. **Use sparse fieldsets** - Add `fields` query parameter: `{"fields": "values.name,values.slug"}`
369 | 2. **Paginate results** - Use `pagelen` and `page` parameters
370 | 3. **Filter at the source** - Use Bitbucket's `q` parameter for server-side filtering
371 | 4. **Post-process with JQ** - Further filter the response with JMESPath
372 |
373 | Example combining all techniques:
374 | ```bash
375 | npx -y @aashari/mcp-server-atlassian-bitbucket get \
376 | --path "/repositories/workspace/repo/pullrequests" \
377 | --query-params '{"state": "OPEN", "pagelen": "10", "fields": "values.id,values.title,values.state"}' \
378 | --jq "values[*].{id: id, title: title}"
379 | ```
380 |
381 | ### Best Practices for AI Interactions
382 |
383 | 1. **Be specific with paths** - Use exact workspace/repo slugs (case-sensitive)
384 | 2. **Test with CLI first** - Verify paths and authentication before using in AI context
385 | 3. **Use descriptive JQ filters** - Extract meaningful field names for better AI understanding
386 | 4. **Enable DEBUG for troubleshooting** - See exactly what's being sent to Bitbucket API
387 | 5. **Check API limits** - Bitbucket Cloud has rate limits; use filtering to reduce calls
388 |
389 | ## CLI Commands
390 |
391 | The CLI mirrors the MCP tools for direct terminal access. All commands return JSON output (not TOON - TOON is only for MCP mode).
392 |
393 | ### Available Commands
394 |
395 | ```bash
396 | # Get help
397 | npx -y @aashari/mcp-server-atlassian-bitbucket --help
398 |
399 | # GET request
400 | npx -y @aashari/mcp-server-atlassian-bitbucket get \
401 | --path "/workspaces" \
402 | --jq "values[*].{name: name, slug: slug}"
403 |
404 | # GET with query parameters
405 | npx -y @aashari/mcp-server-atlassian-bitbucket get \
406 | --path "/repositories/myworkspace/myrepo/pullrequests" \
407 | --query-params '{"state": "OPEN", "pagelen": "10"}' \
408 | --jq "values[*].{id: id, title: title}"
409 |
410 | # POST request (create a PR)
411 | npx -y @aashari/mcp-server-atlassian-bitbucket post \
412 | --path "/repositories/myworkspace/myrepo/pullrequests" \
413 | --body '{"title": "My PR", "source": {"branch": {"name": "feature"}}, "destination": {"branch": {"name": "main"}}}' \
414 | --jq "{id: id, title: title}"
415 |
416 | # POST with query parameters
417 | npx -y @aashari/mcp-server-atlassian-bitbucket post \
418 | --path "/repositories/myworkspace/myrepo/pullrequests/42/comments" \
419 | --body '{"content": {"raw": "Looks good!"}}' \
420 | --query-params '{"fields": "id,content"}' \
421 | --jq "{id: id, content: content.raw}"
422 |
423 | # PUT request (replace resource)
424 | npx -y @aashari/mcp-server-atlassian-bitbucket put \
425 | --path "/repositories/myworkspace/myrepo" \
426 | --body '{"description": "Updated description", "is_private": true}'
427 |
428 | # PATCH request (partial update)
429 | npx -y @aashari/mcp-server-atlassian-bitbucket patch \
430 | --path "/repositories/myworkspace/myrepo/pullrequests/123" \
431 | --body '{"title": "Updated PR title"}'
432 |
433 | # DELETE request
434 | npx -y @aashari/mcp-server-atlassian-bitbucket delete \
435 | --path "/repositories/myworkspace/myrepo/refs/branches/old-branch"
436 |
437 | # Clone repository
438 | npx -y @aashari/mcp-server-atlassian-bitbucket clone \
439 | --workspace-slug myworkspace \
440 | --repo-slug myrepo \
441 | --target-path /absolute/path/to/parent/directory
442 | ```
443 |
444 | ### CLI Options
445 |
446 | **For `get` and `delete` commands:**
447 | - `-p, --path <path>` (required) - API endpoint path
448 | - `-q, --query-params <json>` (optional) - Query parameters as JSON string
449 | - `--jq <expression>` (optional) - JMESPath filter expression
450 |
451 | **For `post`, `put`, and `patch` commands:**
452 | - `-p, --path <path>` (required) - API endpoint path
453 | - `-b, --body <json>` (required) - Request body as JSON string
454 | - `-q, --query-params <json>` (optional) - Query parameters as JSON string
455 | - `--jq <expression>` (optional) - JMESPath filter expression
456 |
457 | **For `clone` command:**
458 | - `--workspace-slug <slug>` (optional) - Workspace slug (uses default if not provided)
459 | - `--repo-slug <slug>` (required) - Repository slug
460 | - `--target-path <path>` (required) - Absolute path to parent directory where repo will be cloned
461 |
462 | ## Debugging
463 |
464 | ### Enable Debug Mode
465 |
466 | Set the `DEBUG` environment variable to see detailed logging:
467 |
468 | ```bash
469 | # For CLI testing
470 | DEBUG=true npx -y @aashari/mcp-server-atlassian-bitbucket get --path "/workspaces"
471 |
472 | # For Claude Desktop - add to config
473 | {
474 | "mcpServers": {
475 | "bitbucket": {
476 | "command": "npx",
477 | "args": ["-y", "@aashari/mcp-server-atlassian-bitbucket"],
478 | "env": {
479 | "DEBUG": "true",
480 | "ATLASSIAN_USER_EMAIL": "...",
481 | "ATLASSIAN_API_TOKEN": "..."
482 | }
483 | }
484 | }
485 | }
486 | ```
487 |
488 | **Log files:** When running in MCP mode, logs are written to `~/.mcp/data/@aashari-mcp-server-atlassian-bitbucket.[session-id].log`
489 |
490 | ### Test with HTTP Mode
491 |
492 | For interactive debugging, run the server in HTTP mode and use the MCP Inspector:
493 |
494 | ```bash
495 | # Set credentials first
496 | export ATLASSIAN_USER_EMAIL="[email protected]"
497 | export ATLASSIAN_API_TOKEN="your_token"
498 | export DEBUG=true
499 |
500 | # Start HTTP server with MCP Inspector
501 | npx -y @aashari/mcp-server-atlassian-bitbucket
502 | # Then in another terminal:
503 | PORT=3000 npm run mcp:inspect
504 | ```
505 |
506 | This opens a visual interface to test tools and see request/response data.
507 |
508 | ### Common Issues
509 |
510 | **Server not appearing in Claude Desktop:**
511 | 1. Check config file syntax (valid JSON)
512 | 2. Restart Claude Desktop completely
513 | 3. Check Claude Desktop logs: `~/Library/Logs/Claude/mcp*.log` (macOS)
514 |
515 | **Tools not working:**
516 | 1. Enable DEBUG mode to see detailed errors
517 | 2. Test with CLI first to isolate MCP vs credentials issues
518 | 3. Verify API paths are correct (case-sensitive)
519 |
520 | ## Troubleshooting
521 |
522 | ### "Authentication failed" or "403 Forbidden"
523 |
524 | 1. **Choose the right authentication method**:
525 | - **Standard Atlassian method** (recommended): Use your Atlassian account email + API token (works with any Atlassian service)
526 | - **Bitbucket-specific method** (legacy): Use your Bitbucket username + App password (Bitbucket only)
527 |
528 | 2. **For Scoped API Tokens** (recommended):
529 | - Go to [Atlassian API Tokens](https://id.atlassian.com/manage-profile/security/api-tokens)
530 | - Make sure your token is still active and has the right scopes
531 | - Required scopes: `repository`, `workspace` (add `pullrequest` for PR management)
532 | - Token should start with `ATATT`
533 |
534 | 3. **For Bitbucket App Passwords** (legacy):
535 | - Go to [Bitbucket App Passwords](https://bitbucket.org/account/settings/app-passwords/)
536 | - Make sure your app password has the right permissions
537 | - Remember: App passwords will be deprecated by June 2026
538 |
539 | 4. **Verify your credentials**:
540 | ```bash
541 | # Test credentials with CLI
542 | export ATLASSIAN_USER_EMAIL="[email protected]"
543 | export ATLASSIAN_API_TOKEN="your_token"
544 | npx -y @aashari/mcp-server-atlassian-bitbucket get --path "/workspaces"
545 | ```
546 |
547 | 5. **Environment variable naming**:
548 | - Use `ATLASSIAN_USER_EMAIL` + `ATLASSIAN_API_TOKEN` for scoped tokens
549 | - Use `ATLASSIAN_BITBUCKET_USERNAME` + `ATLASSIAN_BITBUCKET_APP_PASSWORD` for app passwords
550 | - Don't use `ATLASSIAN_SITE_NAME` - it's not needed for Bitbucket Cloud
551 |
552 | ### "Resource not found" or "404"
553 |
554 | 1. **Check the API path**:
555 | - Paths are case-sensitive
556 | - Use workspace slug (from URL), not display name
557 | - Example: If your repo URL is `https://bitbucket.org/myteam/my-repo`, use `myteam` and `my-repo`
558 |
559 | 2. **Verify the resource exists**:
560 | ```bash
561 | # List workspaces to find the correct slug
562 | npx -y @aashari/mcp-server-atlassian-bitbucket get --path "/workspaces"
563 | ```
564 |
565 | ### Claude Desktop Integration Issues
566 |
567 | 1. **Restart Claude Desktop** after updating the config file
568 | 2. **Verify config file location**:
569 | - macOS: `~/.claude/claude_desktop_config.json`
570 | - Windows: `%APPDATA%\Claude\claude_desktop_config.json`
571 |
572 | ### Getting Help
573 |
574 | If you're still having issues:
575 | 1. Run a simple test command to verify everything works
576 | 2. Check the [GitHub Issues](https://github.com/aashari/mcp-server-atlassian-bitbucket/issues) for similar problems
577 | 3. Create a new issue with your error message and setup details
578 |
579 | ## Frequently Asked Questions
580 |
581 | ### What permissions do I need?
582 |
583 | **For Scoped API Tokens** (recommended):
584 | - Required scopes: `repository`, `workspace`
585 | - Add `pullrequest` for PR management
586 |
587 | **For Bitbucket App Passwords** (legacy):
588 | - For **read-only access**: Workspaces: Read, Repositories: Read, Pull Requests: Read
589 | - For **full functionality**: Add "Write" permissions for Repositories and Pull Requests
590 |
591 | ### Can I use this with private repositories?
592 |
593 | Yes! This works with both public and private repositories. You just need the appropriate permissions through your credentials.
594 |
595 | ### What AI assistants does this work with?
596 |
597 | Any AI assistant that supports the Model Context Protocol (MCP):
598 | - Claude Desktop
599 | - Cursor AI
600 | - Continue.dev
601 | - Many others
602 |
603 | ### Is my data secure?
604 |
605 | Yes! This tool:
606 | - Runs entirely on your local machine
607 | - Uses your own Bitbucket credentials
608 | - Never sends your data to third parties
609 | - Only accesses what you give it permission to access
610 |
611 | ## What's New
612 |
613 | ### Version 2.2.0 (December 2024)
614 | - Modernized to MCP SDK v1.23.0 with `registerTool` API
615 | - Added raw response logging with truncation for large API responses
616 | - Improved debugging capabilities
617 |
618 | ### Version 2.1.0 (November 2024)
619 | - **TOON output format** - 30-60% fewer tokens than JSON
620 | - Token-efficient responses by default with JSON fallback option
621 | - Significant cost reduction for LLM interactions
622 |
623 | ### Version 2.0.0 (November 2024) - Breaking Changes
624 | - Replaced 20+ specific tools with 6 generic HTTP method tools
625 | - Simplified architecture: ~14,000 fewer lines of code
626 | - Future-proof: new API endpoints work without code changes
627 | - Added optional JMESPath filtering for all responses
628 |
629 | ## Migration from v1.x
630 |
631 | Version 2.0 represents a major architectural change. If you're upgrading from v1.x:
632 |
633 | **Before (v1.x) - 20+ specific tools:**
634 | ```
635 | bb_ls_workspaces, bb_get_workspace, bb_ls_repos, bb_get_repo,
636 | bb_list_branches, bb_add_branch, bb_get_commit_history, bb_get_file,
637 | bb_ls_prs, bb_get_pr, bb_add_pr, bb_update_pr, bb_approve_pr, bb_reject_pr,
638 | bb_ls_pr_comments, bb_add_pr_comment, bb_diff_branches, bb_diff_commits, bb_search
639 | ```
640 |
641 | **After (v2.0+) - 6 generic tools:**
642 | ```
643 | bb_get, bb_post, bb_put, bb_patch, bb_delete, bb_clone
644 | ```
645 |
646 | ### Migration Examples
647 |
648 | | v1.x Tool | v2.0+ Equivalent |
649 | |-----------|------------------|
650 | | `bb_ls_workspaces()` | `bb_get(path: "/workspaces")` |
651 | | `bb_ls_repos(workspace: "myteam")` | `bb_get(path: "/repositories/myteam")` |
652 | | `bb_get_repo(workspace: "myteam", repo: "myrepo")` | `bb_get(path: "/repositories/myteam/myrepo")` |
653 | | `bb_list_branches(workspace: "myteam", repo: "myrepo")` | `bb_get(path: "/repositories/myteam/myrepo/refs/branches")` |
654 | | `bb_add_branch(...)` | `bb_post(path: "/repositories/.../refs/branches", body: {...})` |
655 | | `bb_ls_prs(workspace: "myteam", repo: "myrepo")` | `bb_get(path: "/repositories/myteam/myrepo/pullrequests")` |
656 | | `bb_get_pr(workspace: "myteam", repo: "myrepo", id: 42)` | `bb_get(path: "/repositories/myteam/myrepo/pullrequests/42")` |
657 | | `bb_add_pr(...)` | `bb_post(path: "/repositories/.../pullrequests", body: {...})` |
658 | | `bb_update_pr(...)` | `bb_patch(path: "/repositories/.../pullrequests/42", body: {...})` |
659 | | `bb_approve_pr(workspace: "myteam", repo: "myrepo", id: 42)` | `bb_post(path: "/repositories/myteam/myrepo/pullrequests/42/approve", body: {})` |
660 | | `bb_diff_branches(...)` | `bb_get(path: "/repositories/.../diff/branch1..branch2")` |
661 |
662 | ### Key Changes
663 | 1. **All tools now require explicit paths** - more verbose but more flexible
664 | 2. **Use JMESPath filtering** - extract only what you need to reduce tokens
665 | 3. **TOON format by default** - 30-60% fewer tokens (can override with `outputFormat: "json"`)
666 | 4. **Direct Bitbucket API access** - any API endpoint works, no code changes needed for new features
667 |
668 | ## Support
669 |
670 | Need help? Here's how to get assistance:
671 |
672 | 1. **Check the troubleshooting section above** - most common issues are covered there
673 | 2. **Visit our GitHub repository** for documentation and examples: [github.com/aashari/mcp-server-atlassian-bitbucket](https://github.com/aashari/mcp-server-atlassian-bitbucket)
674 | 3. **Report issues** at [GitHub Issues](https://github.com/aashari/mcp-server-atlassian-bitbucket/issues)
675 | 4. **Start a discussion** for feature requests or general questions
676 |
677 | ---
678 |
679 | *Made with care for developers who want to bring AI into their Bitbucket workflow.*
680 |
```
--------------------------------------------------------------------------------
/scripts/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "type": "module"
3 | }
```
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
```yaml
1 | version: 2
2 | updates:
3 | - package-ecosystem: "npm"
4 | directory: "/"
5 | schedule:
6 | interval: "weekly"
7 | open-pull-requests-limit: 10
8 | versioning-strategy: auto
9 | labels:
10 | - "dependencies"
11 | commit-message:
12 | prefix: "chore"
13 | include: "scope"
14 | allow:
15 | - dependency-type: "direct"
16 | ignore:
17 | - dependency-name: "*"
18 | update-types: ["version-update:semver-patch"]
19 | - package-ecosystem: "github-actions"
20 | directory: "/"
21 | schedule:
22 | interval: "weekly"
23 | open-pull-requests-limit: 5
24 | labels:
25 | - "dependencies"
26 | - "github-actions"
```
--------------------------------------------------------------------------------
/.github/workflows/ci-dependency-check.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: CI - Dependency Check
2 |
3 | on:
4 | schedule:
5 | - cron: '0 5 * * 1' # Run at 5 AM UTC every Monday
6 | workflow_dispatch: # Allow manual triggering
7 |
8 | jobs:
9 | validate:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Checkout code
13 | uses: actions/checkout@v5
14 |
15 | - name: Setup Node.js
16 | uses: actions/setup-node@v5
17 | with:
18 | node-version: '22'
19 | cache: 'npm'
20 |
21 | - name: Install dependencies
22 | run: npm ci
23 |
24 | - name: Run npm audit
25 | run: npm audit
26 |
27 | - name: Check for outdated dependencies
28 | run: npm outdated || true
29 |
30 | - name: Run tests
31 | run: npm test
32 |
33 | - name: Run linting
34 | run: npm run lint
35 |
36 | - name: Build project
37 | run: npm run build
38 |
```
--------------------------------------------------------------------------------
/src/utils/jest.setup.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Jest global setup for suppressing console output during tests
3 | * This file is used to mock console methods to reduce noise in test output
4 | */
5 |
6 | import { jest, beforeEach, afterEach, afterAll } from '@jest/globals';
7 |
8 | // Store original console methods
9 | const originalConsole = {
10 | log: console.log,
11 | info: console.info,
12 | warn: console.warn,
13 | error: console.error,
14 | debug: console.debug,
15 | };
16 |
17 | // Global setup to suppress console output during tests
18 | beforeEach(() => {
19 | // Mock console methods to suppress output
20 | console.log = jest.fn();
21 | console.info = jest.fn();
22 | console.warn = jest.fn();
23 | console.error = jest.fn();
24 | console.debug = jest.fn();
25 | });
26 |
27 | afterEach(() => {
28 | // Clear mock calls after each test
29 | jest.clearAllMocks();
30 | });
31 |
32 | afterAll(() => {
33 | // Restore original console methods after all tests
34 | console.log = originalConsole.log;
35 | console.info = originalConsole.info;
36 | console.warn = originalConsole.warn;
37 | console.error = originalConsole.error;
38 | console.debug = originalConsole.debug;
39 | });
40 |
```
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
```
1 | import eslint from '@eslint/js';
2 | import tseslint from 'typescript-eslint';
3 | import prettierPlugin from 'eslint-plugin-prettier';
4 | import eslintConfigPrettier from 'eslint-config-prettier';
5 |
6 | export default tseslint.config(
7 | {
8 | ignores: ['node_modules/**', 'dist/**', 'examples/**'],
9 | },
10 | eslint.configs.recommended,
11 | ...tseslint.configs.recommended,
12 | {
13 | plugins: {
14 | prettier: prettierPlugin,
15 | },
16 | rules: {
17 | 'prettier/prettier': 'error',
18 | indent: ['error', 'tab', { SwitchCase: 1 }],
19 | '@typescript-eslint/no-unused-vars': [
20 | 'error',
21 | { argsIgnorePattern: '^_' },
22 | ],
23 | },
24 | languageOptions: {
25 | parserOptions: {
26 | ecmaVersion: 'latest',
27 | sourceType: 'module',
28 | },
29 | globals: {
30 | node: 'readonly',
31 | jest: 'readonly',
32 | },
33 | },
34 | },
35 | // Special rules for test files
36 | {
37 | files: ['**/*.test.ts'],
38 | rules: {
39 | '@typescript-eslint/no-explicit-any': 'off',
40 | '@typescript-eslint/no-require-imports': 'off',
41 | '@typescript-eslint/no-unsafe-function-type': 'off',
42 | '@typescript-eslint/no-unused-vars': 'off',
43 | },
44 | },
45 | eslintConfigPrettier,
46 | );
47 |
```
--------------------------------------------------------------------------------
/.github/workflows/ci-dependabot-auto-merge.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: CI - Dependabot Auto-merge
2 |
3 | on:
4 | pull_request:
5 | branches: [main]
6 |
7 | permissions:
8 | contents: write
9 | pull-requests: write
10 | checks: read
11 |
12 | jobs:
13 | auto-merge-dependabot:
14 | runs-on: ubuntu-latest
15 | if: github.actor == 'dependabot[bot]'
16 | steps:
17 | - name: Checkout code
18 | uses: actions/checkout@v5
19 |
20 | - name: Setup Node.js
21 | uses: actions/setup-node@v5
22 | with:
23 | node-version: '22'
24 | cache: 'npm'
25 |
26 | - name: Install dependencies
27 | run: npm ci
28 |
29 | - name: Run tests
30 | run: npm test
31 |
32 | - name: Run linting
33 | run: npm run lint
34 |
35 | - name: Auto-approve PR
36 | uses: hmarr/auto-approve-action@v4
37 | with:
38 | github-token: ${{ secrets.GITHUB_TOKEN }}
39 |
40 | - name: Enable auto-merge
41 | if: success()
42 | run: gh pr merge --auto --merge "$PR_URL"
43 | env:
44 | PR_URL: ${{ github.event.pull_request.html_url }}
45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
46 |
```
--------------------------------------------------------------------------------
/src/utils/constants.util.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Application constants
3 | *
4 | * This file contains constants used throughout the application.
5 | * Centralizing these values makes them easier to maintain and update.
6 | */
7 |
8 | /**
9 | * Current application version
10 | * This should match the version in package.json
11 | */
12 | export const VERSION = '1.23.6';
13 |
14 | /**
15 | * Package name with scope
16 | * Used for initialization and identification
17 | */
18 | export const PACKAGE_NAME = '@aashari/mcp-server-atlassian-bitbucket';
19 |
20 | /**
21 | * CLI command name
22 | * Used for binary name and CLI help text
23 | */
24 | export const CLI_NAME = 'mcp-atlassian-bitbucket';
25 |
26 | /**
27 | * Network timeout constants (in milliseconds)
28 | */
29 | export const NETWORK_TIMEOUTS = {
30 | /** Default timeout for API requests (30 seconds) */
31 | DEFAULT_REQUEST_TIMEOUT: 30 * 1000,
32 |
33 | /** Timeout for large file operations like diffs (60 seconds) */
34 | LARGE_REQUEST_TIMEOUT: 60 * 1000,
35 |
36 | /** Timeout for search operations (45 seconds) */
37 | SEARCH_REQUEST_TIMEOUT: 45 * 1000,
38 | } as const;
39 |
40 | /**
41 | * Data limits to prevent excessive resource consumption (CWE-770)
42 | */
43 | export const DATA_LIMITS = {
44 | /** Maximum response size in bytes (10MB) */
45 | MAX_RESPONSE_SIZE: 10 * 1024 * 1024,
46 |
47 | /** Maximum items per page for paginated requests */
48 | MAX_PAGE_SIZE: 100,
49 |
50 | /** Default page size when not specified */
51 | DEFAULT_PAGE_SIZE: 50,
52 | } as const;
53 |
```
--------------------------------------------------------------------------------
/scripts/ensure-executable.js:
--------------------------------------------------------------------------------
```javascript
1 | #!/usr/bin/env node
2 | import fs from 'fs';
3 | import path from 'path';
4 | import { fileURLToPath } from 'url';
5 |
6 | // Use dynamic import meta for ESM compatibility
7 | const __filename = fileURLToPath(import.meta.url);
8 | const __dirname = path.dirname(__filename);
9 | const rootDir = path.resolve(__dirname, '..');
10 | const entryPoint = path.join(rootDir, 'dist', 'index.js');
11 |
12 | try {
13 | if (fs.existsSync(entryPoint)) {
14 | // Ensure the file is executable (cross-platform)
15 | const currentMode = fs.statSync(entryPoint).mode;
16 | // Check if executable bits are set (user, group, or other)
17 | // Mode constants differ slightly across platforms, checking broadly
18 | const isExecutable =
19 | currentMode & fs.constants.S_IXUSR ||
20 | currentMode & fs.constants.S_IXGRP ||
21 | currentMode & fs.constants.S_IXOTH;
22 |
23 | if (!isExecutable) {
24 | // Set permissions to 755 (rwxr-xr-x) if not executable
25 | fs.chmodSync(entryPoint, 0o755);
26 | console.log(
27 | `Made ${path.relative(rootDir, entryPoint)} executable`,
28 | );
29 | } else {
30 | // console.log(`${path.relative(rootDir, entryPoint)} is already executable`);
31 | }
32 | } else {
33 | // console.warn(`${path.relative(rootDir, entryPoint)} not found, skipping chmod`);
34 | }
35 | } catch (err) {
36 | // console.warn(`Failed to set executable permissions: ${err.message}`);
37 | // We use '|| true' in package.json, so no need to exit here
38 | }
39 |
```
--------------------------------------------------------------------------------
/src/cli/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Command } from 'commander';
2 | import { Logger } from '../utils/logger.util.js';
3 | import { VERSION, CLI_NAME } from '../utils/constants.util.js';
4 |
5 | // Import CLI modules
6 | import atlassianApiCli from './atlassian.api.cli.js';
7 | import atlassianRepositoriesCli from './atlassian.repositories.cli.js';
8 |
9 | // Package description
10 | const DESCRIPTION =
11 | 'A Model Context Protocol (MCP) server for Atlassian Bitbucket integration';
12 |
13 | // Create a contextualized logger for this file
14 | const cliLogger = Logger.forContext('cli/index.ts');
15 |
16 | // Log CLI initialization
17 | cliLogger.debug('Bitbucket CLI module initialized');
18 |
19 | export async function runCli(args: string[]) {
20 | const methodLogger = Logger.forContext('cli/index.ts', 'runCli');
21 |
22 | const program = new Command();
23 |
24 | program.name(CLI_NAME).description(DESCRIPTION).version(VERSION);
25 |
26 | // Register CLI commands
27 | atlassianApiCli.register(program);
28 | cliLogger.debug('API commands registered (get, post)');
29 |
30 | atlassianRepositoriesCli.register(program);
31 | cliLogger.debug('Repository commands registered (clone)');
32 |
33 | // Handle unknown commands
34 | program.on('command:*', (operands) => {
35 | methodLogger.error(`Unknown command: ${operands[0]}`);
36 | console.log('');
37 | program.help();
38 | process.exit(1);
39 | });
40 |
41 | // Parse arguments; default to help if no command provided
42 | await program.parseAsync(args.length ? args : ['--help'], { from: 'user' });
43 | }
44 |
```
--------------------------------------------------------------------------------
/src/types/common.types.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Common type definitions shared across controllers.
3 | * These types provide a standard interface for controller interactions.
4 | * Centralized here to ensure consistency across the codebase.
5 | */
6 |
7 | /**
8 | * Common pagination information for API responses.
9 | * This is used for providing consistent pagination details internally.
10 | * Its formatted representation will be included directly in the content string.
11 | */
12 | export interface ResponsePagination {
13 | /**
14 | * Cursor for the next page of results, if available.
15 | * This should be passed to subsequent requests to retrieve the next page.
16 | */
17 | nextCursor?: string;
18 |
19 | /**
20 | * Whether more results are available beyond the current page.
21 | * When true, clients should use the nextCursor to retrieve more results.
22 | */
23 | hasMore: boolean;
24 |
25 | /**
26 | * The number of items in the current result set.
27 | * This helps clients track how many items they've received.
28 | */
29 | count?: number;
30 |
31 | /**
32 | * The total number of items available across all pages, if known.
33 | * Note: Not all APIs provide this. Check the specific API/tool documentation.
34 | */
35 | total?: number;
36 |
37 | /**
38 | * Page number for page-based pagination.
39 | */
40 | page?: number;
41 |
42 | /**
43 | * Page size for page-based pagination.
44 | */
45 | size?: number;
46 | }
47 |
48 | /**
49 | * Common response structure for controller operations.
50 | * All controller methods should return this structure.
51 | */
52 | export interface ControllerResponse {
53 | /**
54 | * Formatted content to be displayed to the user.
55 | * Contains a comprehensive Markdown-formatted string that includes all information:
56 | * - Primary content (e.g., list items, details)
57 | * - Any metadata (previously in metadata field)
58 | * - Pagination information (previously in pagination field)
59 | */
60 | content: string;
61 |
62 | /**
63 | * Optional path to the raw API response file.
64 | * When the response is truncated, this path allows AI to access the full data.
65 | */
66 | rawResponsePath?: string | null;
67 | }
68 |
```
--------------------------------------------------------------------------------
/src/utils/shell.util.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { promisify } from 'util';
2 | import { exec as callbackExec } from 'child_process';
3 | import { Logger } from './logger.util.js';
4 |
5 | const exec = promisify(callbackExec);
6 | const utilLogger = Logger.forContext('utils/shell.util.ts');
7 |
8 | /**
9 | * Executes a shell command.
10 | *
11 | * @param command The command string to execute.
12 | * @param operationDesc A brief description of the operation for logging purposes.
13 | * @returns A promise that resolves with the stdout of the command.
14 | * @throws An error if the command execution fails, including stderr.
15 | */
16 | export async function executeShellCommand(
17 | command: string,
18 | operationDesc: string,
19 | ): Promise<string> {
20 | const methodLogger = utilLogger.forMethod('executeShellCommand');
21 | methodLogger.debug(`Attempting to ${operationDesc}: ${command}`);
22 | try {
23 | const { stdout, stderr } = await exec(command);
24 | if (stderr) {
25 | methodLogger.warn(`Stderr from ${operationDesc}: ${stderr}`);
26 | // Depending on the command, stderr might not always indicate a failure,
27 | // but for git clone, it usually does if stdout is empty.
28 | // If stdout is also present, it might be a warning.
29 | }
30 | methodLogger.info(
31 | `Successfully executed ${operationDesc}. Stdout: ${stdout}`,
32 | );
33 | return stdout || `Successfully ${operationDesc}.`; // Return stdout or a generic success message
34 | } catch (error: unknown) {
35 | methodLogger.error(`Failed to ${operationDesc}: ${command}`, error);
36 |
37 | let errorMessage = 'Unknown error during shell command execution.';
38 | if (error instanceof Error) {
39 | // Node's child_process.ExecException often has stdout and stderr properties
40 | const execError = error as Error & {
41 | stdout?: string;
42 | stderr?: string;
43 | };
44 | errorMessage =
45 | execError.stderr || execError.stdout || execError.message;
46 | } else if (typeof error === 'string') {
47 | errorMessage = error;
48 | }
49 | // Ensure a default message if somehow it's still undefined (though unlikely with above checks)
50 | if (!errorMessage && error) {
51 | errorMessage = String(error);
52 | }
53 |
54 | throw new Error(`Failed to ${operationDesc}: ${errorMessage}`);
55 | }
56 | }
57 |
```
--------------------------------------------------------------------------------
/src/cli/atlassian.repositories.cli.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Command } from 'commander';
2 | import { Logger } from '../utils/logger.util.js';
3 | import { handleCliError } from '../utils/error.util.js';
4 | import { handleCloneRepository } from '../controllers/atlassian.repositories.content.controller.js';
5 |
6 | /**
7 | * CLI module for Bitbucket repository operations.
8 | * Provides the clone command. Other operations (list repos, branches, etc.)
9 | * are available via the generic 'get' command.
10 | */
11 |
12 | // Create a contextualized logger for this file
13 | const cliLogger = Logger.forContext('cli/atlassian.repositories.cli.ts');
14 |
15 | // Log CLI initialization
16 | cliLogger.debug('Bitbucket repositories CLI module initialized');
17 |
18 | /**
19 | * Register Bitbucket repositories CLI commands with the Commander program
20 | *
21 | * @param program - The Commander program instance to register commands with
22 | */
23 | function register(program: Command): void {
24 | const methodLogger = Logger.forContext(
25 | 'cli/atlassian.repositories.cli.ts',
26 | 'register',
27 | );
28 | methodLogger.debug('Registering Bitbucket Repositories CLI commands...');
29 |
30 | program
31 | .command('clone')
32 | .description(
33 | 'Clone a Bitbucket repository to your local filesystem using SSH (preferred) or HTTPS.',
34 | )
35 | .requiredOption('-r, --repo-slug <slug>', 'Repository slug to clone.')
36 | .requiredOption(
37 | '-t, --target-path <path>',
38 | 'Directory path where the repository will be cloned (absolute path recommended).',
39 | )
40 | .option(
41 | '-w, --workspace-slug <slug>',
42 | 'Workspace slug containing the repository. Uses default workspace if not provided.',
43 | )
44 | .action(async (options) => {
45 | const actionLogger = cliLogger.forMethod('clone');
46 | try {
47 | actionLogger.debug(
48 | 'Processing clone command options:',
49 | options,
50 | );
51 |
52 | const result = await handleCloneRepository({
53 | workspaceSlug: options.workspaceSlug,
54 | repoSlug: options.repoSlug,
55 | targetPath: options.targetPath,
56 | });
57 |
58 | console.log(result.content);
59 | } catch (error) {
60 | actionLogger.error('Clone operation failed:', error);
61 | handleCliError(error);
62 | }
63 | });
64 |
65 | methodLogger.debug('CLI commands registered successfully');
66 | }
67 |
68 | export default { register };
69 |
```
--------------------------------------------------------------------------------
/src/tools/atlassian.repositories.tool.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2 | import { Logger } from '../utils/logger.util.js';
3 | import { formatErrorForMcpTool } from '../utils/error.util.js';
4 | import { truncateForAI } from '../utils/formatter.util.js';
5 | import {
6 | CloneRepositoryToolArgs,
7 | type CloneRepositoryToolArgsType,
8 | } from './atlassian.repositories.types.js';
9 |
10 | // Import directly from specialized controllers
11 | import { handleCloneRepository } from '../controllers/atlassian.repositories.content.controller.js';
12 |
13 | // Create a contextualized logger for this file
14 | const toolLogger = Logger.forContext('tools/atlassian.repositories.tool.ts');
15 |
16 | // Log tool initialization
17 | toolLogger.debug('Bitbucket repositories tool initialized');
18 |
19 | /**
20 | * Handler for cloning a repository.
21 | */
22 | async function handleRepoClone(args: Record<string, unknown>) {
23 | const methodLogger = Logger.forContext(
24 | 'tools/atlassian.repositories.tool.ts',
25 | 'handleRepoClone',
26 | );
27 | try {
28 | methodLogger.debug('Cloning repository:', args);
29 |
30 | // Pass args directly to controller
31 | const result = await handleCloneRepository(
32 | args as CloneRepositoryToolArgsType,
33 | );
34 |
35 | methodLogger.debug('Successfully cloned repository via controller');
36 |
37 | return {
38 | content: [
39 | {
40 | type: 'text' as const,
41 | text: truncateForAI(result.content, result.rawResponsePath),
42 | },
43 | ],
44 | };
45 | } catch (error) {
46 | methodLogger.error('Failed to clone repository', error);
47 | return formatErrorForMcpTool(error);
48 | }
49 | }
50 |
51 | // Tool description
52 | const BB_CLONE_DESCRIPTION = `Clone a Bitbucket repository to your local filesystem using SSH (preferred) or HTTPS.
53 |
54 | Provide \`repoSlug\` and \`targetPath\` (absolute path). Clones into \`targetPath/repoSlug\`. SSH keys must be configured; falls back to HTTPS if unavailable.`;
55 |
56 | /**
57 | * Register all Bitbucket repository tools with the MCP server.
58 | * Uses the modern registerTool API (SDK v1.22.0+) instead of deprecated tool() method.
59 | *
60 | * Branch creation is now handled by bb_post tool.
61 | */
62 | function registerTools(server: McpServer) {
63 | const registerLogger = Logger.forContext(
64 | 'tools/atlassian.repositories.tool.ts',
65 | 'registerTools',
66 | );
67 | registerLogger.debug('Registering Repository tools...');
68 |
69 | // Register the clone repository tool using modern registerTool API
70 | server.registerTool(
71 | 'bb_clone',
72 | {
73 | title: 'Clone Bitbucket Repository',
74 | description: BB_CLONE_DESCRIPTION,
75 | inputSchema: CloneRepositoryToolArgs,
76 | },
77 | handleRepoClone,
78 | );
79 |
80 | registerLogger.debug('Successfully registered Repository tools');
81 | }
82 |
83 | export default { registerTools };
84 |
```
--------------------------------------------------------------------------------
/src/utils/jq.util.ts:
--------------------------------------------------------------------------------
```typescript
1 | import jmespath from 'jmespath';
2 | import { Logger } from './logger.util.js';
3 | import { toToonOrJson } from './toon.util.js';
4 |
5 | const logger = Logger.forContext('utils/jq.util.ts');
6 |
7 | /**
8 | * Apply a JMESPath filter to JSON data
9 | *
10 | * @param data - The data to filter (any JSON-serializable value)
11 | * @param filter - JMESPath expression to apply
12 | * @returns Filtered data or original data if filter is empty/invalid
13 | *
14 | * @example
15 | * // Get single field
16 | * applyJqFilter(data, "name")
17 | *
18 | * // Get nested field
19 | * applyJqFilter(data, "links.html.href")
20 | *
21 | * // Get multiple fields as object
22 | * applyJqFilter(data, "{name: name, slug: slug}")
23 | *
24 | * // Array operations
25 | * applyJqFilter(data, "values[*].name")
26 | */
27 | export function applyJqFilter(data: unknown, filter?: string): unknown {
28 | const methodLogger = logger.forMethod('applyJqFilter');
29 |
30 | // Return original data if no filter provided
31 | if (!filter || filter.trim() === '') {
32 | methodLogger.debug('No filter provided, returning original data');
33 | return data;
34 | }
35 |
36 | try {
37 | methodLogger.debug(`Applying JMESPath filter: ${filter}`);
38 | const result = jmespath.search(data, filter);
39 | methodLogger.debug('Filter applied successfully');
40 | return result;
41 | } catch (error) {
42 | methodLogger.error(`Invalid JMESPath expression: ${filter}`, error);
43 | // Return original data with error info if filter is invalid
44 | return {
45 | _jqError: `Invalid JMESPath expression: ${filter}`,
46 | _originalData: data,
47 | };
48 | }
49 | }
50 |
51 | /**
52 | * Convert data to JSON string for MCP response
53 | *
54 | * @param data - The data to stringify
55 | * @param pretty - Whether to pretty-print the JSON (default: true)
56 | * @returns JSON string
57 | */
58 | export function toJsonString(data: unknown, pretty: boolean = true): string {
59 | if (pretty) {
60 | return JSON.stringify(data, null, 2);
61 | }
62 | return JSON.stringify(data);
63 | }
64 |
65 | /**
66 | * Convert data to output string for MCP response
67 | *
68 | * By default, converts to TOON format (Token-Oriented Object Notation)
69 | * for improved LLM token efficiency (30-60% fewer tokens).
70 | * Falls back to JSON if TOON conversion fails or if useToon is false.
71 | *
72 | * @param data - The data to convert
73 | * @param useToon - Whether to use TOON format (default: true)
74 | * @param pretty - Whether to pretty-print JSON (default: true)
75 | * @returns TOON formatted string (default), or JSON string
76 | */
77 | export async function toOutputString(
78 | data: unknown,
79 | useToon: boolean = true,
80 | pretty: boolean = true,
81 | ): Promise<string> {
82 | const jsonString = toJsonString(data, pretty);
83 |
84 | // Return JSON directly if TOON is not requested
85 | if (!useToon) {
86 | return jsonString;
87 | }
88 |
89 | // Try TOON conversion with JSON fallback
90 | return toToonOrJson(data, jsonString);
91 | }
92 |
```
--------------------------------------------------------------------------------
/.github/workflows/ci-semantic-release.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: CI - Semantic Release
2 |
3 | # This workflow is triggered on every push to the main branch
4 | # It analyzes commits and automatically releases a new version when needed
5 | on:
6 | push:
7 | branches: [main]
8 |
9 | jobs:
10 | release:
11 | name: Semantic Release
12 | runs-on: ubuntu-latest
13 | # Permissions needed for creating releases, updating issues, and publishing packages
14 | permissions:
15 | contents: write # Needed to create releases and tags
16 | issues: write # Needed to comment on issues
17 | pull-requests: write # Needed to comment on pull requests
18 | # packages permission removed as we're not using GitHub Packages
19 | steps:
20 | # Step 1: Check out the full Git history for proper versioning
21 | - name: Checkout
22 | uses: actions/checkout@v5
23 | with:
24 | fetch-depth: 0 # Fetches all history for all branches and tags
25 |
26 | # Step 2: Setup Node.js environment
27 | - name: Setup Node.js
28 | uses: actions/setup-node@v5
29 | with:
30 | node-version: 22 # Using Node.js 22
31 | cache: 'npm' # Enable npm caching
32 |
33 | # Step 3: Install dependencies with clean install
34 | - name: Install dependencies
35 | run: npm ci # Clean install preserving package-lock.json
36 |
37 | # Step 4: Build the package
38 | - name: Build
39 | run: npm run build # Compiles TypeScript to JavaScript
40 |
41 | # Step 5: Ensure executable permissions
42 | - name: Set executable permissions
43 | run: chmod +x dist/index.js
44 |
45 | # Step 6: Run tests to ensure quality
46 | - name: Verify tests
47 | run: npm test # Runs Jest tests
48 |
49 | # Step 7: Configure Git identity for releases
50 | - name: Configure Git User
51 | run: |
52 | git config --global user.email "github-actions[bot]@users.noreply.github.com"
53 | git config --global user.name "github-actions[bot]"
54 |
55 | # Step 8: Run semantic-release to analyze commits and publish to npm
56 | - name: Semantic Release
57 | id: semantic
58 | env:
59 | # Tokens needed for GitHub and npm authentication
60 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # For creating releases and commenting
61 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} # For publishing to npm
62 | run: |
63 | echo "Running semantic-release for version bump and npm publishing"
64 | npx semantic-release
65 |
66 | # Note: GitHub Packages publishing has been removed
67 |
```
--------------------------------------------------------------------------------
/src/utils/workspace.util.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Logger } from './logger.util.js';
2 | import { config } from './config.util.js';
3 | import atlassianWorkspacesService from '../services/vendor.atlassian.workspaces.service.js';
4 | import { WorkspaceMembership } from '../services/vendor.atlassian.workspaces.types.js';
5 |
6 | const workspaceLogger = Logger.forContext('utils/workspace.util.ts');
7 |
8 | /**
9 | * Cache for workspace data to avoid repeated API calls
10 | */
11 | let cachedDefaultWorkspace: string | null = null;
12 | let cachedWorkspaces: WorkspaceMembership[] | null = null;
13 |
14 | /**
15 | * Get the default workspace slug
16 | *
17 | * This function follows this priority:
18 | * 1. Use cached value if available
19 | * 2. Check BITBUCKET_DEFAULT_WORKSPACE environment variable
20 | * 3. Fetch from API and use the first workspace in the list
21 | *
22 | * @returns {Promise<string|null>} The default workspace slug or null if not available
23 | */
24 | export async function getDefaultWorkspace(): Promise<string | null> {
25 | const methodLogger = workspaceLogger.forMethod('getDefaultWorkspace');
26 |
27 | // Step 1: Return cached value if available
28 | if (cachedDefaultWorkspace) {
29 | methodLogger.debug(
30 | `Using cached default workspace: ${cachedDefaultWorkspace}`,
31 | );
32 | return cachedDefaultWorkspace;
33 | }
34 |
35 | // Step 2: Check environment variable
36 | const envWorkspace = config.get('BITBUCKET_DEFAULT_WORKSPACE');
37 | if (envWorkspace) {
38 | methodLogger.debug(
39 | `Using default workspace from environment: ${envWorkspace}`,
40 | );
41 | cachedDefaultWorkspace = envWorkspace;
42 | return envWorkspace;
43 | }
44 |
45 | // Step 3: Fetch from API
46 | methodLogger.debug('No default workspace configured, fetching from API...');
47 | try {
48 | const workspaces = await getWorkspaces();
49 |
50 | if (workspaces.length > 0) {
51 | const defaultWorkspace = workspaces[0].workspace.slug;
52 | methodLogger.debug(
53 | `Using first workspace from API as default: ${defaultWorkspace}`,
54 | );
55 | cachedDefaultWorkspace = defaultWorkspace;
56 | return defaultWorkspace;
57 | } else {
58 | methodLogger.warn('No workspaces found in the account');
59 | return null;
60 | }
61 | } catch (error) {
62 | methodLogger.error('Failed to fetch default workspace', error);
63 | return null;
64 | }
65 | }
66 |
67 | /**
68 | * Get list of workspaces from API or cache
69 | *
70 | * @returns {Promise<WorkspaceMembership[]>} Array of workspace membership objects
71 | */
72 | export async function getWorkspaces(): Promise<WorkspaceMembership[]> {
73 | const methodLogger = workspaceLogger.forMethod('getWorkspaces');
74 |
75 | if (cachedWorkspaces) {
76 | methodLogger.debug(
77 | `Using ${cachedWorkspaces.length} cached workspaces`,
78 | );
79 | return cachedWorkspaces;
80 | }
81 |
82 | try {
83 | const result = await atlassianWorkspacesService.list({
84 | pagelen: 10, // Limit to first 10 workspaces
85 | });
86 |
87 | if (result.values) {
88 | cachedWorkspaces = result.values;
89 | methodLogger.debug(`Cached ${result.values.length} workspaces`);
90 | return result.values;
91 | } else {
92 | return [];
93 | }
94 | } catch (error) {
95 | methodLogger.error('Failed to fetch workspaces list', error);
96 | return [];
97 | }
98 | }
99 |
```
--------------------------------------------------------------------------------
/src/utils/toon.util.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Logger } from './logger.util.js';
2 |
3 | const logger = Logger.forContext('utils/toon.util.ts');
4 |
5 | /**
6 | * TOON encode function type (dynamically imported)
7 | */
8 | type ToonEncode = (input: unknown, options?: { indent?: number }) => string;
9 |
10 | /**
11 | * Cached TOON encode function
12 | */
13 | let toonEncode: ToonEncode | null = null;
14 |
15 | /**
16 | * Load the TOON encoder dynamically (ESM module in CommonJS project)
17 | */
18 | async function loadToonEncoder(): Promise<ToonEncode | null> {
19 | if (toonEncode) {
20 | return toonEncode;
21 | }
22 |
23 | try {
24 | const toon = await import('@toon-format/toon');
25 | toonEncode = toon.encode;
26 | logger.debug('TOON encoder loaded successfully');
27 | return toonEncode;
28 | } catch (error) {
29 | logger.error('Failed to load TOON encoder', error);
30 | return null;
31 | }
32 | }
33 |
34 | /**
35 | * Convert data to TOON format with JSON fallback
36 | *
37 | * Attempts to encode data as TOON (Token-Oriented Object Notation) for
38 | * more efficient LLM token usage. Falls back to JSON if TOON encoding fails.
39 | *
40 | * @param data - The data to convert
41 | * @param jsonFallback - The JSON string to return if TOON conversion fails
42 | * @returns TOON formatted string, or JSON fallback on error
43 | *
44 | * @example
45 | * const json = JSON.stringify(data, null, 2);
46 | * const output = await toToonOrJson(data, json);
47 | */
48 | export async function toToonOrJson(
49 | data: unknown,
50 | jsonFallback: string,
51 | ): Promise<string> {
52 | const methodLogger = logger.forMethod('toToonOrJson');
53 |
54 | try {
55 | const encode = await loadToonEncoder();
56 | if (!encode) {
57 | methodLogger.debug(
58 | 'TOON encoder not available, using JSON fallback',
59 | );
60 | return jsonFallback;
61 | }
62 |
63 | const toonResult = encode(data, { indent: 2 });
64 | methodLogger.debug('Successfully converted to TOON format');
65 | return toonResult;
66 | } catch (error) {
67 | methodLogger.error(
68 | 'TOON conversion failed, using JSON fallback',
69 | error,
70 | );
71 | return jsonFallback;
72 | }
73 | }
74 |
75 | /**
76 | * Synchronous TOON conversion with JSON fallback
77 | *
78 | * Uses cached encoder if available, otherwise returns JSON fallback.
79 | * Prefer toToonOrJson for first-time conversion.
80 | *
81 | * @param data - The data to convert
82 | * @param jsonFallback - The JSON string to return if TOON is unavailable
83 | * @returns TOON formatted string, or JSON fallback
84 | */
85 | export function toToonOrJsonSync(data: unknown, jsonFallback: string): string {
86 | const methodLogger = logger.forMethod('toToonOrJsonSync');
87 |
88 | if (!toonEncode) {
89 | methodLogger.debug('TOON encoder not loaded, using JSON fallback');
90 | return jsonFallback;
91 | }
92 |
93 | try {
94 | const toonResult = toonEncode(data, { indent: 2 });
95 | methodLogger.debug('Successfully converted to TOON format');
96 | return toonResult;
97 | } catch (error) {
98 | methodLogger.error(
99 | 'TOON conversion failed, using JSON fallback',
100 | error,
101 | );
102 | return jsonFallback;
103 | }
104 | }
105 |
106 | /**
107 | * Pre-load the TOON encoder for synchronous usage later
108 | * Call this during server initialization
109 | */
110 | export async function preloadToonEncoder(): Promise<boolean> {
111 | const encode = await loadToonEncoder();
112 | return encode !== null;
113 | }
114 |
```
--------------------------------------------------------------------------------
/src/services/vendor.atlassian.workspaces.types.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { z } from 'zod';
2 |
3 | /**
4 | * Types for Atlassian Bitbucket Workspaces API
5 | */
6 |
7 | /**
8 | * Workspace type (basic object)
9 | */
10 | export const WorkspaceTypeSchema = z.literal('workspace');
11 | export type WorkspaceType = z.infer<typeof WorkspaceTypeSchema>;
12 |
13 | /**
14 | * Workspace user object
15 | */
16 | export const WorkspaceUserSchema = z.object({
17 | type: z.literal('user'),
18 | uuid: z.string(),
19 | nickname: z.string(),
20 | display_name: z.string(),
21 | });
22 |
23 | /**
24 | * Workspace permission type
25 | */
26 | export const WorkspacePermissionSchema = z.enum([
27 | 'owner',
28 | 'collaborator',
29 | 'member',
30 | ]);
31 |
32 | /**
33 | * Workspace links object
34 | */
35 | const LinkSchema = z.object({
36 | href: z.string(),
37 | name: z.string().optional(),
38 | });
39 |
40 | export const WorkspaceLinksSchema = z.object({
41 | avatar: LinkSchema.optional(),
42 | html: LinkSchema.optional(),
43 | members: LinkSchema.optional(),
44 | owners: LinkSchema.optional(),
45 | projects: LinkSchema.optional(),
46 | repositories: LinkSchema.optional(),
47 | snippets: LinkSchema.optional(),
48 | self: LinkSchema.optional(),
49 | });
50 | export type WorkspaceLinks = z.infer<typeof WorkspaceLinksSchema>;
51 |
52 | /**
53 | * Workspace forking mode
54 | */
55 | export const WorkspaceForkingModeSchema = z.enum([
56 | 'allow_forks',
57 | 'no_public_forks',
58 | 'no_forks',
59 | ]);
60 | export type WorkspaceForkingMode = z.infer<typeof WorkspaceForkingModeSchema>;
61 |
62 | /**
63 | * Workspace object returned from the API
64 | */
65 | export const WorkspaceSchema: z.ZodType<{
66 | type: WorkspaceType;
67 | uuid: string;
68 | name: string;
69 | slug: string;
70 | is_private?: boolean;
71 | is_privacy_enforced?: boolean;
72 | forking_mode?: WorkspaceForkingMode;
73 | created_on?: string;
74 | updated_on?: string;
75 | links: WorkspaceLinks;
76 | }> = z.object({
77 | type: WorkspaceTypeSchema,
78 | uuid: z.string(),
79 | name: z.string(),
80 | slug: z.string(),
81 | is_private: z.boolean().optional(),
82 | is_privacy_enforced: z.boolean().optional(),
83 | forking_mode: WorkspaceForkingModeSchema.optional(),
84 | created_on: z.string().optional(),
85 | updated_on: z.string().optional(),
86 | links: WorkspaceLinksSchema,
87 | });
88 |
89 | /**
90 | * Workspace membership object
91 | */
92 | export const WorkspaceMembershipSchema = z.object({
93 | type: z.literal('workspace_membership'),
94 | permission: WorkspacePermissionSchema,
95 | last_accessed: z.string().optional(),
96 | added_on: z.string().optional(),
97 | user: WorkspaceUserSchema,
98 | workspace: WorkspaceSchema,
99 | });
100 | export type WorkspaceMembership = z.infer<typeof WorkspaceMembershipSchema>;
101 |
102 | /**
103 | * Extended workspace object with optional fields
104 | * @remarks Currently identical to Workspace, but allows for future extension
105 | */
106 | export const WorkspaceDetailedSchema = WorkspaceSchema;
107 | export type WorkspaceDetailed = z.infer<typeof WorkspaceDetailedSchema>;
108 |
109 | /**
110 | * Parameters for listing workspaces
111 | */
112 | export const ListWorkspacesParamsSchema = z.object({
113 | q: z.string().optional(),
114 | page: z.number().optional(),
115 | pagelen: z.number().optional(),
116 | });
117 | export type ListWorkspacesParams = z.infer<typeof ListWorkspacesParamsSchema>;
118 |
119 | /**
120 | * API response for user permissions on workspaces
121 | */
122 | export const WorkspacePermissionsResponseSchema = z.object({
123 | pagelen: z.number(),
124 | page: z.number(),
125 | size: z.number(),
126 | next: z.string().optional(),
127 | previous: z.string().optional(),
128 | values: z.array(WorkspaceMembershipSchema),
129 | });
130 | export type WorkspacePermissionsResponse = z.infer<
131 | typeof WorkspacePermissionsResponseSchema
132 | >;
133 |
```
--------------------------------------------------------------------------------
/src/utils/response.util.ts:
--------------------------------------------------------------------------------
```typescript
1 | import * as fs from 'fs';
2 | import * as path from 'path';
3 | import * as crypto from 'crypto';
4 | import { Logger } from './logger.util.js';
5 | import { PACKAGE_NAME } from './constants.util.js';
6 |
7 | // Create a contextualized logger for this file
8 | const responseLogger = Logger.forContext('utils/response.util.ts');
9 |
10 | /**
11 | * Get the project name from PACKAGE_NAME, stripping the scope prefix
12 | * e.g., "@aashari/mcp-server-atlassian-bitbucket" -> "mcp-server-atlassian-bitbucket"
13 | */
14 | function getProjectName(): string {
15 | const name = PACKAGE_NAME.replace(/^@[^/]+\//, '');
16 | return name;
17 | }
18 |
19 | /**
20 | * Generate a unique filename with timestamp and random string
21 | * Format: <timestamp>-<random>.txt
22 | */
23 | function generateFilename(): string {
24 | const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
25 | const randomStr = crypto.randomBytes(4).toString('hex');
26 | return `${timestamp}-${randomStr}.txt`;
27 | }
28 |
29 | /**
30 | * Ensure the directory exists, creating it if necessary
31 | */
32 | function ensureDirectoryExists(dirPath: string): void {
33 | if (!fs.existsSync(dirPath)) {
34 | fs.mkdirSync(dirPath, { recursive: true });
35 | responseLogger.debug(`Created directory: ${dirPath}`);
36 | }
37 | }
38 |
39 | /**
40 | * Save raw API response to a file in /tmp/mcp/<project-name>/
41 | *
42 | * @param url The URL that was called
43 | * @param method The HTTP method used
44 | * @param requestBody The request body (if any)
45 | * @param responseData The raw response data
46 | * @param statusCode The HTTP status code
47 | * @param durationMs The request duration in milliseconds
48 | * @returns The path to the saved file, or null if saving failed
49 | */
50 | export function saveRawResponse(
51 | url: string,
52 | method: string,
53 | requestBody: unknown,
54 | responseData: unknown,
55 | statusCode: number,
56 | durationMs: number,
57 | ): string | null {
58 | const methodLogger = Logger.forContext(
59 | 'utils/response.util.ts',
60 | 'saveRawResponse',
61 | );
62 |
63 | try {
64 | const projectName = getProjectName();
65 | const dirPath = path.join('/tmp', 'mcp', projectName);
66 | const filename = generateFilename();
67 | const filePath = path.join(dirPath, filename);
68 |
69 | ensureDirectoryExists(dirPath);
70 |
71 | // Build the content
72 | const content = buildResponseContent(
73 | url,
74 | method,
75 | requestBody,
76 | responseData,
77 | statusCode,
78 | durationMs,
79 | );
80 |
81 | // Write to file
82 | fs.writeFileSync(filePath, content, 'utf8');
83 | methodLogger.debug(`Saved raw response to: ${filePath}`);
84 |
85 | return filePath;
86 | } catch (error) {
87 | methodLogger.error('Failed to save raw response', error);
88 | return null;
89 | }
90 | }
91 |
92 | /**
93 | * Build the content string for the response file
94 | */
95 | function buildResponseContent(
96 | url: string,
97 | method: string,
98 | requestBody: unknown,
99 | responseData: unknown,
100 | statusCode: number,
101 | durationMs: number,
102 | ): string {
103 | const timestamp = new Date().toISOString();
104 | const separator = '='.repeat(80);
105 |
106 | let content = `${separator}
107 | RAW API RESPONSE LOG
108 | ${separator}
109 |
110 | Timestamp: ${timestamp}
111 | URL: ${url}
112 | Method: ${method}
113 | Status Code: ${statusCode}
114 | Duration: ${durationMs.toFixed(2)}ms
115 |
116 | ${separator}
117 | REQUEST BODY
118 | ${separator}
119 | `;
120 |
121 | if (requestBody) {
122 | content +=
123 | typeof requestBody === 'string'
124 | ? requestBody
125 | : JSON.stringify(requestBody, null, 2);
126 | } else {
127 | content += '(no request body)';
128 | }
129 |
130 | content += `
131 |
132 | ${separator}
133 | RESPONSE DATA
134 | ${separator}
135 | `;
136 |
137 | if (responseData !== undefined && responseData !== null) {
138 | content +=
139 | typeof responseData === 'string'
140 | ? responseData
141 | : JSON.stringify(responseData, null, 2);
142 | } else {
143 | content += '(no response data)';
144 | }
145 |
146 | content += `
147 | ${separator}
148 | `;
149 |
150 | return content;
151 | }
152 |
```
--------------------------------------------------------------------------------
/src/tools/atlassian.api.types.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { z } from 'zod';
2 |
3 | /**
4 | * Output format options for API responses
5 | * - toon: Token-Oriented Object Notation (default, more token-efficient for LLMs)
6 | * - json: Standard JSON format
7 | */
8 | export const OutputFormat = z
9 | .enum(['toon', 'json'])
10 | .optional()
11 | .describe(
12 | 'Output format: "toon" (default, 30-60% fewer tokens) or "json". TOON is optimized for LLMs with tabular arrays and minimal syntax.',
13 | );
14 |
15 | /**
16 | * Base schema fields shared by all API tool arguments
17 | * Contains path, queryParams, jq filter, and outputFormat
18 | */
19 | const BaseApiToolArgs = {
20 | /**
21 | * The API endpoint path (without base URL)
22 | * Examples:
23 | * - "/workspaces" - list workspaces
24 | * - "/workspaces/{workspace}" - get workspace details
25 | * - "/repositories/{workspace}/{repo_slug}" - get repository
26 | * - "/repositories/{workspace}/{repo_slug}/pullrequests" - list PRs
27 | * - "/repositories/{workspace}/{repo_slug}/pullrequests/{pull_request_id}" - get PR
28 | * - "/repositories/{workspace}/{repo_slug}/commits" - get commits
29 | * - "/repositories/{workspace}/{repo_slug}/src/{commit}/{path}" - get file content
30 | */
31 | path: z
32 | .string()
33 | .min(1, 'Path is required')
34 | .describe(
35 | 'The Bitbucket API endpoint path (without base URL). Must start with "/". Examples: "/workspaces", "/repositories/{workspace}/{repo_slug}", "/repositories/{workspace}/{repo_slug}/pullrequests/{id}"',
36 | ),
37 |
38 | /**
39 | * Optional query parameters as key-value pairs
40 | */
41 | queryParams: z
42 | .record(z.string(), z.string())
43 | .optional()
44 | .describe(
45 | 'Optional query parameters as key-value pairs. Examples: {"pagelen": "25", "page": "2", "q": "state=\\"OPEN\\"", "fields": "values.title,values.state"}',
46 | ),
47 |
48 | /**
49 | * Optional JMESPath expression to filter/transform the response
50 | * IMPORTANT: Always use this to reduce response size and token costs
51 | */
52 | jq: z
53 | .string()
54 | .optional()
55 | .describe(
56 | 'JMESPath expression to filter/transform the response. IMPORTANT: Always use this to extract only needed fields and reduce token costs. Examples: "values[*].{name: name, slug: slug}" (extract specific fields), "values[0]" (first result), "values[*].name" (names only). See https://jmespath.org',
57 | ),
58 |
59 | /**
60 | * Output format for the response
61 | * Defaults to TOON (token-efficient), can be set to JSON if needed
62 | */
63 | outputFormat: OutputFormat,
64 | };
65 |
66 | /**
67 | * Body field for requests that include a request body (POST, PUT, PATCH)
68 | */
69 | const bodyField = z
70 | .record(z.string(), z.unknown())
71 | .describe(
72 | 'Request body as a JSON object. Structure depends on the endpoint. Example for PR: {"title": "My PR", "source": {"branch": {"name": "feature"}}}',
73 | );
74 |
75 | /**
76 | * Schema for bb_get tool arguments (GET requests - no body)
77 | */
78 | export const GetApiToolArgs = z.object(BaseApiToolArgs);
79 | export type GetApiToolArgsType = z.infer<typeof GetApiToolArgs>;
80 |
81 | /**
82 | * Schema for requests with body (POST, PUT, PATCH)
83 | */
84 | export const RequestWithBodyArgs = z.object({
85 | ...BaseApiToolArgs,
86 | body: bodyField,
87 | });
88 | export type RequestWithBodyArgsType = z.infer<typeof RequestWithBodyArgs>;
89 |
90 | /**
91 | * Schema for bb_post tool arguments (POST requests)
92 | */
93 | export const PostApiToolArgs = RequestWithBodyArgs;
94 | export type PostApiToolArgsType = RequestWithBodyArgsType;
95 |
96 | /**
97 | * Schema for bb_put tool arguments (PUT requests)
98 | */
99 | export const PutApiToolArgs = RequestWithBodyArgs;
100 | export type PutApiToolArgsType = RequestWithBodyArgsType;
101 |
102 | /**
103 | * Schema for bb_patch tool arguments (PATCH requests)
104 | */
105 | export const PatchApiToolArgs = RequestWithBodyArgs;
106 | export type PatchApiToolArgsType = RequestWithBodyArgsType;
107 |
108 | /**
109 | * Schema for bb_delete tool arguments (DELETE requests - no body)
110 | */
111 | export const DeleteApiToolArgs = GetApiToolArgs;
112 | export type DeleteApiToolArgsType = GetApiToolArgsType;
113 |
```
--------------------------------------------------------------------------------
/src/utils/config.util.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import {
2 | ErrorType,
3 | McpError,
4 | createApiError,
5 | createAuthMissingError,
6 | createAuthInvalidError,
7 | createUnexpectedError,
8 | ensureMcpError,
9 | formatErrorForMcpTool,
10 | formatErrorForMcpResource,
11 | } from './error.util.js';
12 |
13 | describe('Error Utility', () => {
14 | describe('McpError', () => {
15 | it('should create an error with the correct properties', () => {
16 | const error = new McpError('Test error', ErrorType.API_ERROR, 404);
17 |
18 | expect(error).toBeInstanceOf(Error);
19 | expect(error).toBeInstanceOf(McpError);
20 | expect(error.message).toBe('Test error');
21 | expect(error.type).toBe(ErrorType.API_ERROR);
22 | expect(error.statusCode).toBe(404);
23 | expect(error.name).toBe('McpError');
24 | });
25 | });
26 |
27 | describe('Error Factory Functions', () => {
28 | it('should create auth missing error', () => {
29 | const error = createAuthMissingError();
30 |
31 | expect(error).toBeInstanceOf(McpError);
32 | expect(error.type).toBe(ErrorType.AUTH_MISSING);
33 | expect(error.message).toBe(
34 | 'Authentication credentials are missing',
35 | );
36 | });
37 |
38 | it('should create auth invalid error', () => {
39 | const error = createAuthInvalidError('Invalid token');
40 |
41 | expect(error).toBeInstanceOf(McpError);
42 | expect(error.type).toBe(ErrorType.AUTH_INVALID);
43 | expect(error.statusCode).toBe(401);
44 | expect(error.message).toBe('Invalid token');
45 | });
46 |
47 | it('should create API error', () => {
48 | const originalError = new Error('Original error');
49 | const error = createApiError('API failed', 500, originalError);
50 |
51 | expect(error).toBeInstanceOf(McpError);
52 | expect(error.type).toBe(ErrorType.API_ERROR);
53 | expect(error.statusCode).toBe(500);
54 | expect(error.message).toBe('API failed');
55 | expect(error.originalError).toBe(originalError);
56 | });
57 |
58 | it('should create unexpected error', () => {
59 | const error = createUnexpectedError();
60 |
61 | expect(error).toBeInstanceOf(McpError);
62 | expect(error.type).toBe(ErrorType.UNEXPECTED_ERROR);
63 | expect(error.message).toBe('An unexpected error occurred');
64 | });
65 | });
66 |
67 | describe('ensureMcpError', () => {
68 | it('should return the same error if it is already an McpError', () => {
69 | const originalError = createApiError('Original error');
70 | const error = ensureMcpError(originalError);
71 |
72 | expect(error).toBe(originalError);
73 | });
74 |
75 | it('should wrap a standard Error', () => {
76 | const originalError = new Error('Standard error');
77 | const error = ensureMcpError(originalError);
78 |
79 | expect(error).toBeInstanceOf(McpError);
80 | expect(error.type).toBe(ErrorType.UNEXPECTED_ERROR);
81 | expect(error.message).toBe('Standard error');
82 | expect(error.originalError).toBe(originalError);
83 | });
84 |
85 | it('should handle non-Error objects', () => {
86 | const error = ensureMcpError('String error');
87 |
88 | expect(error).toBeInstanceOf(McpError);
89 | expect(error.type).toBe(ErrorType.UNEXPECTED_ERROR);
90 | expect(error.message).toBe('String error');
91 | });
92 | });
93 |
94 | describe('formatErrorForMcpTool', () => {
95 | it('should format an error for MCP tool response', () => {
96 | const error = createApiError('API error');
97 | const response = formatErrorForMcpTool(error);
98 |
99 | expect(response).toHaveProperty('content');
100 | expect(response.content).toHaveLength(1);
101 | expect(response.content[0]).toHaveProperty('type', 'text');
102 | expect(response.content[0]).toHaveProperty(
103 | 'text',
104 | 'Error: API error',
105 | );
106 | });
107 | });
108 |
109 | describe('formatErrorForMcpResource', () => {
110 | it('should format an error for MCP resource response', () => {
111 | const error = createApiError('API error');
112 | const response = formatErrorForMcpResource(error, 'test://uri');
113 |
114 | expect(response).toHaveProperty('contents');
115 | expect(response.contents).toHaveLength(1);
116 | expect(response.contents[0]).toHaveProperty('uri', 'test://uri');
117 | expect(response.contents[0]).toHaveProperty(
118 | 'text',
119 | 'Error: API error',
120 | );
121 | expect(response.contents[0]).toHaveProperty(
122 | 'mimeType',
123 | 'text/plain',
124 | );
125 | expect(response.contents[0]).toHaveProperty(
126 | 'description',
127 | 'Error: API_ERROR',
128 | );
129 | });
130 | });
131 | });
132 |
```
--------------------------------------------------------------------------------
/src/utils/cli.test.util.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { spawn } from 'child_process';
2 | import { join } from 'path';
3 |
4 | /**
5 | * Utility for testing CLI commands with real execution
6 | */
7 | export class CliTestUtil {
8 | /**
9 | * Executes a CLI command and returns the result
10 | *
11 | * @param args - CLI arguments to pass to the command
12 | * @param options - Test options
13 | * @returns Promise with stdout, stderr, and exit code
14 | */
15 | static async runCommand(
16 | args: string[],
17 | options: {
18 | timeoutMs?: number;
19 | env?: Record<string, string>;
20 | } = {},
21 | ): Promise<{
22 | stdout: string;
23 | stderr: string;
24 | exitCode: number;
25 | }> {
26 | // Default timeout of 30 seconds
27 | const timeoutMs = options.timeoutMs || 30000;
28 |
29 | // CLI execution path - points to the built CLI script
30 | const cliPath = join(process.cwd(), 'dist', 'index.js');
31 |
32 | return new Promise((resolve, reject) => {
33 | // Set up timeout handler
34 | const timeoutId = setTimeout(() => {
35 | child.kill();
36 | reject(new Error(`CLI command timed out after ${timeoutMs}ms`));
37 | }, timeoutMs);
38 |
39 | // Capture stdout and stderr
40 | let stdout = '';
41 | let stderr = '';
42 |
43 | // Spawn the process with given arguments
44 | const child = spawn('node', [cliPath, ...args], {
45 | env: {
46 | ...process.env,
47 | ...options.env,
48 | },
49 | });
50 |
51 | // Collect stdout data
52 | child.stdout.on('data', (data) => {
53 | stdout += data.toString();
54 | });
55 |
56 | // Collect stderr data
57 | child.stderr.on('data', (data) => {
58 | stderr += data.toString();
59 | });
60 |
61 | // Handle process completion
62 | child.on('close', (exitCode) => {
63 | clearTimeout(timeoutId);
64 | resolve({
65 | stdout,
66 | stderr,
67 | exitCode: exitCode ?? 0,
68 | });
69 | });
70 |
71 | // Handle process errors
72 | child.on('error', (err) => {
73 | clearTimeout(timeoutId);
74 | reject(err);
75 | });
76 | });
77 | }
78 |
79 | /**
80 | * Validates that stdout contains expected strings/patterns
81 | */
82 | static validateOutputContains(
83 | output: string,
84 | expectedPatterns: (string | RegExp)[],
85 | ): void {
86 | for (const pattern of expectedPatterns) {
87 | if (typeof pattern === 'string') {
88 | expect(output).toContain(pattern);
89 | } else {
90 | expect(output).toMatch(pattern);
91 | }
92 | }
93 | }
94 |
95 | /**
96 | * Validates Markdown output format
97 | */
98 | static validateMarkdownOutput(output: string): void {
99 | // Check for Markdown heading
100 | expect(output).toMatch(/^#\s.+/m);
101 |
102 | // Check for markdown formatting elements like bold text, lists, etc.
103 | const markdownElements = [
104 | /\*\*.+\*\*/, // Bold text
105 | /-\s.+/, // List items
106 | /\|.+\|.+\|/, // Table rows
107 | /\[.+\]\(.+\)/, // Links
108 | ];
109 |
110 | expect(markdownElements.some((pattern) => pattern.test(output))).toBe(
111 | true,
112 | );
113 | }
114 |
115 | /**
116 | * Extracts and parses JSON from CLI output
117 | * Handles output that may contain log lines before the JSON
118 | *
119 | * @param output - The CLI output string
120 | * @returns Parsed JSON object or null if no valid JSON found
121 | */
122 | static extractJsonFromOutput(
123 | output: string,
124 | ): Record<string, unknown> | null {
125 | // Split by newlines and find lines that could be start of JSON
126 | const lines = output.split('\n');
127 | let jsonStartIndex = -1;
128 |
129 | // Find the first line that starts with '{' (the actual JSON output)
130 | for (let i = 0; i < lines.length; i++) {
131 | const trimmed = lines[i].trim();
132 | if (trimmed.startsWith('{') && !trimmed.includes('[')) {
133 | // This looks like start of JSON, not a log line with timestamp
134 | jsonStartIndex = i;
135 | break;
136 | }
137 | }
138 |
139 | if (jsonStartIndex === -1) {
140 | return null;
141 | }
142 |
143 | // Join from the JSON start to the end
144 | const jsonStr = lines.slice(jsonStartIndex).join('\n');
145 |
146 | try {
147 | return JSON.parse(jsonStr);
148 | } catch {
149 | // Try to find the matching closing brace
150 | let braceCount = 0;
151 | let endIndex = 0;
152 | for (let i = 0; i < jsonStr.length; i++) {
153 | if (jsonStr[i] === '{') braceCount++;
154 | if (jsonStr[i] === '}') braceCount--;
155 | if (braceCount === 0) {
156 | endIndex = i + 1;
157 | break;
158 | }
159 | }
160 | if (endIndex > 0) {
161 | try {
162 | return JSON.parse(jsonStr.substring(0, endIndex));
163 | } catch {
164 | return null;
165 | }
166 | }
167 | return null;
168 | }
169 | }
170 | }
171 |
```
--------------------------------------------------------------------------------
/src/utils/bitbucket-error-detection.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, expect, test } from '@jest/globals';
2 | import { detectErrorType, ErrorCode } from './error-handler.util.js';
3 | import { createApiError } from './error.util.js';
4 |
5 | describe('Bitbucket Error Detection', () => {
6 | describe('Classic Bitbucket error structure: { error: { message, detail } }', () => {
7 | test('detects not found errors', () => {
8 | // Create a mock Bitbucket error structure
9 | const bitbucketError = {
10 | error: {
11 | message: 'Repository not found',
12 | detail: 'The repository does not exist or you do not have access',
13 | },
14 | };
15 | const mcpError = createApiError('API Error', 404, bitbucketError);
16 |
17 | const result = detectErrorType(mcpError);
18 | expect(result).toEqual({
19 | code: ErrorCode.NOT_FOUND,
20 | statusCode: 404,
21 | });
22 | });
23 |
24 | test('detects access denied errors', () => {
25 | const bitbucketError = {
26 | error: {
27 | message: 'Access denied to this repository',
28 | detail: 'You need admin permissions to perform this action',
29 | },
30 | };
31 | const mcpError = createApiError('API Error', 403, bitbucketError);
32 |
33 | const result = detectErrorType(mcpError);
34 | expect(result).toEqual({
35 | code: ErrorCode.ACCESS_DENIED,
36 | statusCode: 403,
37 | });
38 | });
39 |
40 | test('detects validation errors', () => {
41 | const bitbucketError = {
42 | error: {
43 | message: 'Invalid parameter: repository name',
44 | detail: 'Repository name can only contain alphanumeric characters',
45 | },
46 | };
47 | const mcpError = createApiError('API Error', 400, bitbucketError);
48 |
49 | const result = detectErrorType(mcpError);
50 | expect(result).toEqual({
51 | code: ErrorCode.VALIDATION_ERROR,
52 | statusCode: 400,
53 | });
54 | });
55 |
56 | test('detects rate limit errors', () => {
57 | const bitbucketError = {
58 | error: {
59 | message: 'Too many requests',
60 | detail: 'Rate limit exceeded. Try again later.',
61 | },
62 | };
63 | const mcpError = createApiError('API Error', 429, bitbucketError);
64 |
65 | const result = detectErrorType(mcpError);
66 | expect(result).toEqual({
67 | code: ErrorCode.RATE_LIMIT_ERROR,
68 | statusCode: 429,
69 | });
70 | });
71 | });
72 |
73 | describe('Alternate Bitbucket error structure: { type: "error", ... }', () => {
74 | test('detects not found errors', () => {
75 | const altBitbucketError = {
76 | type: 'error',
77 | status: 404,
78 | message: 'Resource not found',
79 | };
80 | const mcpError = createApiError(
81 | 'API Error',
82 | 404,
83 | altBitbucketError,
84 | );
85 |
86 | const result = detectErrorType(mcpError);
87 | expect(result).toEqual({
88 | code: ErrorCode.NOT_FOUND,
89 | statusCode: 404,
90 | });
91 | });
92 |
93 | test('detects access denied errors', () => {
94 | const altBitbucketError = {
95 | type: 'error',
96 | status: 403,
97 | message: 'Forbidden',
98 | };
99 | const mcpError = createApiError(
100 | 'API Error',
101 | 403,
102 | altBitbucketError,
103 | );
104 |
105 | const result = detectErrorType(mcpError);
106 | expect(result).toEqual({
107 | code: ErrorCode.ACCESS_DENIED,
108 | statusCode: 403,
109 | });
110 | });
111 | });
112 |
113 | describe('Bitbucket errors array structure: { errors: [{ ... }] }', () => {
114 | test('detects errors from array structure', () => {
115 | const arrayBitbucketError = {
116 | errors: [
117 | {
118 | status: 400,
119 | code: 'INVALID_REQUEST_PARAMETER',
120 | title: 'Invalid parameter value',
121 | message: 'The parameter is not valid',
122 | },
123 | ],
124 | };
125 | const mcpError = createApiError(
126 | 'API Error',
127 | 400,
128 | arrayBitbucketError,
129 | );
130 |
131 | const result = detectErrorType(mcpError);
132 | expect(result).toEqual({
133 | code: ErrorCode.VALIDATION_ERROR,
134 | statusCode: 400,
135 | });
136 | });
137 | });
138 |
139 | describe('Network errors in Bitbucket context', () => {
140 | test('detects network errors from TypeError', () => {
141 | const networkError = new TypeError('Failed to fetch');
142 | const mcpError = createApiError('Network Error', 500, networkError);
143 |
144 | const result = detectErrorType(mcpError);
145 | expect(result).toEqual({
146 | code: ErrorCode.NETWORK_ERROR,
147 | statusCode: 500,
148 | });
149 | });
150 |
151 | test('detects other common network error messages', () => {
152 | const errorMessages = [
153 | 'network error occurred',
154 | 'ECONNREFUSED',
155 | 'ENOTFOUND',
156 | 'Network request failed',
157 | 'Failed to fetch',
158 | ];
159 |
160 | errorMessages.forEach((msg) => {
161 | const error = new Error(msg);
162 | const result = detectErrorType(error);
163 | expect(result).toEqual({
164 | code: ErrorCode.NETWORK_ERROR,
165 | statusCode: 500,
166 | });
167 | });
168 | });
169 | });
170 | });
171 |
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "@aashari/mcp-server-atlassian-bitbucket",
3 | "version": "2.3.0",
4 | "description": "Node.js/TypeScript MCP server for Atlassian Bitbucket. Enables AI systems (LLMs) to interact with workspaces, repositories, and pull requests via tools (list, get, comment, search). Connects AI directly to version control workflows through the standard MCP interface.",
5 | "main": "dist/index.js",
6 | "types": "dist/index.d.ts",
7 | "type": "commonjs",
8 | "repository": {
9 | "type": "git",
10 | "url": "https://github.com/aashari/mcp-server-atlassian-bitbucket.git"
11 | },
12 | "bin": {
13 | "mcp-atlassian-bitbucket": "./dist/index.js"
14 | },
15 | "scripts": {
16 | "build": "tsc",
17 | "prepare": "npm run build && node scripts/ensure-executable.js",
18 | "postinstall": "node scripts/ensure-executable.js",
19 | "clean": "rm -rf dist coverage",
20 | "test": "jest",
21 | "test:coverage": "jest --coverage",
22 | "test:cli": "jest src/cli/.*\\.cli\\.test\\.ts --runInBand --testTimeout=60000",
23 | "lint": "eslint src --ext .ts --config eslint.config.mjs",
24 | "format": "prettier --write 'src/**/*.ts' 'scripts/**/*.js'",
25 | "publish:npm": "npm publish",
26 | "update:check": "npx npm-check-updates",
27 | "update:deps": "npx npm-check-updates -u && npm install --legacy-peer-deps",
28 | "update:version": "node scripts/update-version.js",
29 | "mcp:stdio": "TRANSPORT_MODE=stdio npm run build && node dist/index.js",
30 | "mcp:http": "TRANSPORT_MODE=http npm run build && node dist/index.js",
31 | "mcp:inspect": "TRANSPORT_MODE=http npm run build && (node dist/index.js &) && sleep 2 && npx @modelcontextprotocol/inspector http://localhost:3000/mcp",
32 | "dev:stdio": "npm run build && npx @modelcontextprotocol/inspector -e TRANSPORT_MODE=stdio -e DEBUG=true node dist/index.js",
33 | "dev:http": "DEBUG=true TRANSPORT_MODE=http npm run build && node dist/index.js",
34 | "dev:server": "DEBUG=true npm run build && npx @modelcontextprotocol/inspector -e DEBUG=true node dist/index.js",
35 | "dev:cli": "DEBUG=true npm run build && DEBUG=true node dist/index.js",
36 | "start:server": "npm run build && npx @modelcontextprotocol/inspector node dist/index.js",
37 | "start:cli": "npm run build && node dist/index.js",
38 | "cli": "npm run build && node dist/index.js"
39 | },
40 | "keywords": [
41 | "mcp",
42 | "typescript",
43 | "claude",
44 | "anthropic",
45 | "ai",
46 | "atlassian",
47 | "bitbucket",
48 | "repository",
49 | "version-control",
50 | "pull-request",
51 | "server",
52 | "model-context-protocol",
53 | "tools",
54 | "resources",
55 | "tooling",
56 | "ai-integration",
57 | "mcp-server",
58 | "llm",
59 | "ai-connector",
60 | "external-tools",
61 | "cli",
62 | "mcp-inspector"
63 | ],
64 | "author": "Andi Ashari",
65 | "license": "ISC",
66 | "devDependencies": {
67 | "@eslint/js": "^9.39.1",
68 | "@semantic-release/changelog": "^6.0.3",
69 | "@semantic-release/exec": "^7.1.0",
70 | "@semantic-release/git": "^10.0.1",
71 | "@semantic-release/github": "^12.0.2",
72 | "@semantic-release/npm": "^13.1.2",
73 | "@types/cors": "^2.8.19",
74 | "@types/express": "^5.0.5",
75 | "@types/jest": "^30.0.0",
76 | "@types/jmespath": "^0.15.2",
77 | "@types/node": "^24.10.1",
78 | "@typescript-eslint/eslint-plugin": "^8.48.0",
79 | "@typescript-eslint/parser": "^8.48.0",
80 | "eslint": "^9.39.1",
81 | "eslint-config-prettier": "^10.1.8",
82 | "eslint-plugin-filenames": "^1.3.2",
83 | "eslint-plugin-prettier": "^5.5.4",
84 | "jest": "^30.2.0",
85 | "nodemon": "^3.1.11",
86 | "npm-check-updates": "^19.1.2",
87 | "prettier": "^3.7.3",
88 | "semantic-release": "^25.0.2",
89 | "ts-jest": "^29.4.5",
90 | "ts-node": "^10.9.2",
91 | "typescript": "^5.9.3",
92 | "typescript-eslint": "^8.48.0"
93 | },
94 | "publishConfig": {
95 | "registry": "https://registry.npmjs.org/",
96 | "access": "public"
97 | },
98 | "dependencies": {
99 | "@modelcontextprotocol/sdk": "^1.23.0",
100 | "@toon-format/toon": "^2.0.1",
101 | "commander": "^14.0.2",
102 | "cors": "^2.8.5",
103 | "dotenv": "^17.2.3",
104 | "express": "^5.1.0",
105 | "jmespath": "^0.16.0",
106 | "zod": "^4.1.13"
107 | },
108 | "directories": {
109 | "example": "examples"
110 | },
111 | "jest": {
112 | "preset": "ts-jest",
113 | "testEnvironment": "node",
114 | "setupFilesAfterEnv": [
115 | "<rootDir>/src/utils/jest.setup.ts"
116 | ],
117 | "testMatch": [
118 | "**/src/**/*.test.ts"
119 | ],
120 | "collectCoverageFrom": [
121 | "src/**/*.ts",
122 | "!src/**/*.test.ts",
123 | "!src/**/*.spec.ts"
124 | ],
125 | "coveragePathIgnorePatterns": [
126 | "/node_modules/",
127 | "/dist/",
128 | "/coverage/"
129 | ],
130 | "coverageReporters": [
131 | "text",
132 | "lcov",
133 | "json-summary"
134 | ],
135 | "transform": {
136 | "^.+\\.tsx?$": [
137 | "ts-jest",
138 | {
139 | "useESM": true
140 | }
141 | ]
142 | },
143 | "moduleNameMapper": {
144 | "(.*)\\.(js|jsx)$": "$1"
145 | },
146 | "extensionsToTreatAsEsm": [
147 | ".ts"
148 | ],
149 | "moduleFileExtensions": [
150 | "ts",
151 | "tsx",
152 | "js",
153 | "jsx",
154 | "json",
155 | "node"
156 | ]
157 | },
158 | "engines": {
159 | "node": ">=18.0.0"
160 | }
161 | }
162 |
```
--------------------------------------------------------------------------------
/src/utils/toon.util.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, expect, test } from '@jest/globals';
2 | import { toToonOrJson, toToonOrJsonSync } from './toon.util.js';
3 |
4 | /**
5 | * NOTE: The TOON encoder (@toon-format/toon) is an ESM-only package.
6 | * In Jest's CommonJS test environment, dynamic imports may not work,
7 | * causing TOON conversion to fall back to JSON. These tests verify:
8 | * 1. The fallback mechanism works correctly
9 | * 2. Functions return valid output (either TOON or JSON fallback)
10 | * 3. Error handling is robust
11 | *
12 | * TOON conversion is verified at runtime via CLI/integration tests.
13 | */
14 |
15 | describe('TOON Utilities', () => {
16 | describe('toToonOrJson', () => {
17 | test('returns valid output for simple object', async () => {
18 | const data = { name: 'Alice', age: 30 };
19 | const jsonFallback = JSON.stringify(data, null, 2);
20 |
21 | const result = await toToonOrJson(data, jsonFallback);
22 |
23 | // Should return either TOON or JSON fallback
24 | expect(result).toBeDefined();
25 | expect(result.length).toBeGreaterThan(0);
26 | // Should contain the data values regardless of format
27 | expect(result).toContain('Alice');
28 | expect(result).toContain('30');
29 | });
30 |
31 | test('returns valid output for array of objects', async () => {
32 | const data = {
33 | users: [
34 | { id: 1, name: 'Alice', role: 'admin' },
35 | { id: 2, name: 'Bob', role: 'user' },
36 | ],
37 | };
38 | const jsonFallback = JSON.stringify(data, null, 2);
39 |
40 | const result = await toToonOrJson(data, jsonFallback);
41 |
42 | expect(result).toBeDefined();
43 | expect(result).toContain('Alice');
44 | expect(result).toContain('Bob');
45 | });
46 |
47 | test('returns valid output for nested object', async () => {
48 | const data = {
49 | context: {
50 | task: 'Test task',
51 | location: 'Test location',
52 | },
53 | items: ['a', 'b', 'c'],
54 | };
55 | const jsonFallback = JSON.stringify(data, null, 2);
56 |
57 | const result = await toToonOrJson(data, jsonFallback);
58 |
59 | expect(result).toBeDefined();
60 | expect(result).toContain('Test task');
61 | expect(result).toContain('Test location');
62 | });
63 |
64 | test('handles primitive values', async () => {
65 | const stringData = 'hello';
66 | const numberData = 42;
67 | const boolData = true;
68 | const nullData = null;
69 |
70 | // All primitives should produce valid output
71 | const strResult = await toToonOrJson(stringData, '"hello"');
72 | const numResult = await toToonOrJson(numberData, '42');
73 | const boolResult = await toToonOrJson(boolData, 'true');
74 | const nullResult = await toToonOrJson(nullData, 'null');
75 |
76 | expect(strResult).toContain('hello');
77 | expect(numResult).toContain('42');
78 | expect(boolResult).toContain('true');
79 | expect(nullResult).toContain('null');
80 | });
81 |
82 | test('handles empty objects and arrays', async () => {
83 | const emptyObj = {};
84 | const emptyArr: unknown[] = [];
85 |
86 | const objResult = await toToonOrJson(emptyObj, '{}');
87 | const arrResult = await toToonOrJson(emptyArr, '[]');
88 |
89 | expect(objResult).toBeDefined();
90 | expect(arrResult).toBeDefined();
91 | });
92 |
93 | test('returns fallback when data contains special characters', async () => {
94 | const data = { message: 'Hello\nWorld', path: '/some/path' };
95 | const jsonFallback = JSON.stringify(data, null, 2);
96 |
97 | const result = await toToonOrJson(data, jsonFallback);
98 |
99 | expect(result).toBeDefined();
100 | expect(result.length).toBeGreaterThan(0);
101 | });
102 | });
103 |
104 | describe('toToonOrJsonSync', () => {
105 | test('returns JSON fallback when encoder not loaded', () => {
106 | const data = { name: 'Test', value: 123 };
107 | const jsonFallback = JSON.stringify(data, null, 2);
108 |
109 | // Without preloading, sync version should return fallback
110 | const result = toToonOrJsonSync(data, jsonFallback);
111 |
112 | expect(result).toBeDefined();
113 | expect(result).toContain('Test');
114 | expect(result).toContain('123');
115 | });
116 |
117 | test('handles complex data gracefully', () => {
118 | const data = {
119 | pages: [
120 | { id: 1, title: 'Page One' },
121 | { id: 2, title: 'Page Two' },
122 | ],
123 | };
124 | const jsonFallback = JSON.stringify(data, null, 2);
125 |
126 | const result = toToonOrJsonSync(data, jsonFallback);
127 |
128 | expect(result).toBeDefined();
129 | expect(result).toContain('Page One');
130 | expect(result).toContain('Page Two');
131 | });
132 | });
133 |
134 | describe('Fallback behavior', () => {
135 | test('fallback JSON is valid and parseable', async () => {
136 | const data = {
137 | spaces: [
138 | { id: '123', name: 'Engineering', key: 'ENG' },
139 | { id: '456', name: 'Product', key: 'PROD' },
140 | ],
141 | };
142 | const jsonFallback = JSON.stringify(data, null, 2);
143 |
144 | const result = await toToonOrJson(data, jsonFallback);
145 |
146 | // If it's JSON fallback, it should be parseable
147 | // If it's TOON, this will fail, but the test still passes
148 | // because we're just checking the result is valid
149 | expect(result).toBeDefined();
150 | expect(result.length).toBeGreaterThan(0);
151 | });
152 |
153 | test('function does not throw on edge case data', async () => {
154 | // Test with various edge cases (excluding undefined which JSON.stringify handles specially)
155 | const testCases = [
156 | { data: null, fallback: 'null' },
157 | { data: 0, fallback: '0' },
158 | { data: '', fallback: '""' },
159 | { data: [], fallback: '[]' },
160 | { data: {}, fallback: '{}' },
161 | { data: { deep: { nested: { value: 1 } } }, fallback: '{}' },
162 | ];
163 |
164 | for (const { data, fallback } of testCases) {
165 | // Should not throw
166 | const result = await toToonOrJson(data, fallback);
167 | expect(result).toBeDefined();
168 | }
169 | });
170 | });
171 | });
172 |
```
--------------------------------------------------------------------------------
/src/tools/atlassian.repositories.types.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { z } from 'zod';
2 |
3 | /**
4 | * Repository tool types.
5 | */
6 |
7 | /**
8 | * Base pagination arguments for all tools
9 | */
10 | const PaginationArgs = {
11 | limit: z
12 | .number()
13 | .int()
14 | .positive()
15 | .max(100)
16 | .optional()
17 | .describe(
18 | 'Maximum number of items to return (1-100). Controls the response size. Defaults to 25 if omitted.',
19 | ),
20 |
21 | cursor: z
22 | .string()
23 | .optional()
24 | .describe(
25 | 'Pagination cursor for retrieving the next set of results. Obtained from previous response when more results are available.',
26 | ),
27 | };
28 |
29 | /**
30 | * Schema for list-repositories tool arguments
31 | */
32 | export const ListRepositoriesToolArgs = z.object({
33 | /**
34 | * Workspace slug containing the repositories
35 | */
36 | workspaceSlug: z
37 | .string()
38 | .optional()
39 | .describe(
40 | 'Workspace slug containing the repositories. If not provided, the system will use your default workspace (either configured via BITBUCKET_DEFAULT_WORKSPACE or the first workspace in your account). Example: "myteam"',
41 | ),
42 |
43 | /**
44 | * Optional query to filter repositories
45 | */
46 | query: z
47 | .string()
48 | .optional()
49 | .describe(
50 | 'Query string to filter repositories by name or other properties (text search). Example: "api" for repositories with "api" in the name/description. If omitted, returns all repositories.',
51 | ),
52 |
53 | /**
54 | * Optional sort parameter
55 | */
56 | sort: z
57 | .string()
58 | .optional()
59 | .describe(
60 | 'Field to sort results by. Common values: "name", "created_on", "updated_on". Prefix with "-" for descending order. Example: "-updated_on" for most recently updated first.',
61 | ),
62 |
63 | /**
64 | * Optional role filter
65 | */
66 | role: z
67 | .string()
68 | .optional()
69 | .describe(
70 | 'Filter repositories by the authenticated user\'s role. Common values: "owner", "admin", "contributor", "member". If omitted, returns repositories of all roles.',
71 | ),
72 |
73 | /**
74 | * Optional project key filter
75 | */
76 | projectKey: z
77 | .string()
78 | .optional()
79 | .describe('Filter repositories by project key. Example: "project-api"'),
80 |
81 | /**
82 | * Maximum number of repositories to return (default: 25)
83 | */
84 | ...PaginationArgs,
85 | });
86 |
87 | export type ListRepositoriesToolArgsType = z.infer<
88 | typeof ListRepositoriesToolArgs
89 | >;
90 |
91 | /**
92 | * Schema for create-branch tool arguments.
93 | */
94 | export const CreateBranchToolArgsSchema = z.object({
95 | workspaceSlug: z
96 | .string()
97 | .optional()
98 | .describe(
99 | 'Workspace slug containing the repository. If not provided, the system will use your default workspace (either configured via BITBUCKET_DEFAULT_WORKSPACE or the first workspace in your account). Example: "myteam"',
100 | ),
101 | repoSlug: z
102 | .string()
103 | .min(1, 'Repository slug is required')
104 | .describe('Repository slug where the branch will be created.'),
105 | newBranchName: z
106 | .string()
107 | .min(1, 'New branch name is required')
108 | .describe('The name for the new branch.'),
109 | sourceBranchOrCommit: z
110 | .string()
111 | .min(1, 'Source branch or commit is required')
112 | .describe('The name of the branch or the commit hash to branch from.'),
113 | });
114 |
115 | export type CreateBranchToolArgsType = z.infer<
116 | typeof CreateBranchToolArgsSchema
117 | >;
118 |
119 | /**
120 | * Schema for clone-repository tool arguments.
121 | */
122 | export const CloneRepositoryToolArgs = z.object({
123 | workspaceSlug: z
124 | .string()
125 | .optional()
126 | .describe(
127 | 'Bitbucket workspace slug containing the repository. If not provided, the tool will use your default workspace (either configured via BITBUCKET_DEFAULT_WORKSPACE or the first workspace in your account). Example: "myteam"',
128 | ),
129 | repoSlug: z
130 | .string()
131 | .min(1, 'Repository slug is required')
132 | .describe(
133 | 'Repository name/slug to clone. This is the short name of the repository. Example: "project-api"',
134 | ),
135 | targetPath: z
136 | .string()
137 | .min(1, 'Target path is required')
138 | .describe(
139 | 'Directory path where the repository will be cloned. IMPORTANT: Absolute paths are strongly recommended (e.g., "/home/user/projects" or "C:\\Users\\name\\projects"). Relative paths will be resolved relative to the server\'s working directory, which may not be what you expect. The repository will be cloned into a subdirectory at targetPath/repoSlug. Make sure you have write permissions to this location.',
140 | ),
141 | });
142 |
143 | export type CloneRepositoryToolArgsType = z.infer<
144 | typeof CloneRepositoryToolArgs
145 | >;
146 |
147 | /**
148 | * Schema for list-branches tool arguments
149 | */
150 | export const ListBranchesToolArgs = z.object({
151 | /**
152 | * Workspace slug containing the repository
153 | */
154 | workspaceSlug: z
155 | .string()
156 | .optional()
157 | .describe(
158 | 'Workspace slug containing the repository. If not provided, the system will use your default workspace. Example: "myteam"',
159 | ),
160 |
161 | /**
162 | * Repository slug to list branches from
163 | */
164 | repoSlug: z
165 | .string()
166 | .min(1, 'Repository slug is required')
167 | .describe(
168 | 'Repository slug to list branches from. Must be a valid repository slug in the specified workspace. Example: "project-api"',
169 | ),
170 |
171 | /**
172 | * Optional query to filter branches
173 | */
174 | query: z
175 | .string()
176 | .optional()
177 | .describe(
178 | 'Query string to filter branches by name or other properties (text search).',
179 | ),
180 |
181 | /**
182 | * Optional sort parameter
183 | */
184 | sort: z
185 | .string()
186 | .optional()
187 | .describe(
188 | 'Field to sort branches by. Common values: "name" (default), "-name", "target.date". Prefix with "-" for descending order.',
189 | ),
190 |
191 | /**
192 | * Maximum number of branches to return (default: 25)
193 | */
194 | ...PaginationArgs,
195 | });
196 |
197 | export type ListBranchesToolArgsType = z.infer<typeof ListBranchesToolArgs>;
198 |
```
--------------------------------------------------------------------------------
/scripts/update-version.js:
--------------------------------------------------------------------------------
```javascript
1 | #!/usr/bin/env node
2 |
3 | /**
4 | * Script to update version numbers across the project
5 | * Usage: node scripts/update-version.js [version] [options]
6 | * Options:
7 | * --dry-run Show what changes would be made without applying them
8 | * --verbose Show detailed logging information
9 | *
10 | * If no version is provided, it will use the version from package.json
11 | */
12 |
13 | import fs from 'fs';
14 | import path from 'path';
15 | import { fileURLToPath } from 'url';
16 |
17 | // Get the directory name of the current module
18 | const __filename = fileURLToPath(import.meta.url);
19 | const __dirname = path.dirname(__filename);
20 | const rootDir = path.resolve(__dirname, '..');
21 |
22 | // Parse command line arguments
23 | const args = process.argv.slice(2);
24 | const options = {
25 | dryRun: args.includes('--dry-run'),
26 | verbose: args.includes('--verbose'),
27 | };
28 |
29 | // Get the version (first non-flag argument)
30 | let newVersion = args.find((arg) => !arg.startsWith('--'));
31 |
32 | // Log helper function
33 | const log = (message, verbose = false) => {
34 | if (!verbose || options.verbose) {
35 | console.log(message);
36 | }
37 | };
38 |
39 | // File paths that may contain version information
40 | const versionFiles = [
41 | {
42 | path: path.join(rootDir, 'package.json'),
43 | pattern: /"version": "([^"]*)"/,
44 | replacement: (match, currentVersion) =>
45 | match.replace(currentVersion, newVersion),
46 | },
47 | {
48 | path: path.join(rootDir, 'src', 'utils', 'constants.util.ts'),
49 | pattern: /export const VERSION = ['"]([^'"]*)['"]/,
50 | replacement: (match, currentVersion) =>
51 | match.replace(currentVersion, newVersion),
52 | },
53 | // Also update the compiled JavaScript files if they exist
54 | {
55 | path: path.join(rootDir, 'dist', 'utils', 'constants.util.js'),
56 | pattern: /exports.VERSION = ['"]([^'"]*)['"]/,
57 | replacement: (match, currentVersion) =>
58 | match.replace(currentVersion, newVersion),
59 | optional: true, // Mark this file as optional
60 | },
61 | // Additional files can be added here with their patterns and replacement logic
62 | ];
63 |
64 | /**
65 | * Read the version from package.json
66 | * @returns {string} The version from package.json
67 | */
68 | function getPackageVersion() {
69 | try {
70 | const packageJsonPath = path.join(rootDir, 'package.json');
71 | log(`Reading version from ${packageJsonPath}`, true);
72 |
73 | const packageJson = JSON.parse(
74 | fs.readFileSync(packageJsonPath, 'utf8'),
75 | );
76 |
77 | if (!packageJson.version) {
78 | throw new Error('No version field found in package.json');
79 | }
80 |
81 | return packageJson.version;
82 | } catch (error) {
83 | console.error(`Error reading package.json: ${error.message}`);
84 | process.exit(1);
85 | }
86 | }
87 |
88 | /**
89 | * Validate the semantic version format
90 | * @param {string} version - The version to validate
91 | * @returns {boolean} True if valid, throws error if invalid
92 | */
93 | function validateVersion(version) {
94 | // More comprehensive semver regex
95 | const semverRegex =
96 | /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
97 |
98 | if (!semverRegex.test(version)) {
99 | throw new Error(
100 | `Invalid version format: ${version}\nPlease use semantic versioning format (e.g., 1.2.3, 1.2.3-beta.1, etc.)`,
101 | );
102 | }
103 |
104 | return true;
105 | }
106 |
107 | /**
108 | * Update version in a specific file
109 | * @param {Object} fileConfig - Configuration for the file to update
110 | */
111 | function updateFileVersion(fileConfig) {
112 | const {
113 | path: filePath,
114 | pattern,
115 | replacement,
116 | optional = false,
117 | } = fileConfig;
118 |
119 | try {
120 | log(`Checking ${filePath}...`, true);
121 |
122 | if (!fs.existsSync(filePath)) {
123 | if (optional) {
124 | log(`Optional file not found (skipping): ${filePath}`, true);
125 | return;
126 | }
127 | console.warn(`Warning: File not found: ${filePath}`);
128 | return;
129 | }
130 |
131 | // Read file content
132 | const fileContent = fs.readFileSync(filePath, 'utf8');
133 | const match = fileContent.match(pattern);
134 |
135 | if (!match) {
136 | console.warn(`Warning: Version pattern not found in ${filePath}`);
137 | return;
138 | }
139 |
140 | const currentVersion = match[1];
141 | if (currentVersion === newVersion) {
142 | log(
143 | `Version in ${path.basename(filePath)} is already ${newVersion}`,
144 | true,
145 | );
146 | return;
147 | }
148 |
149 | // Create new content with the updated version
150 | const updatedContent = fileContent.replace(pattern, replacement);
151 |
152 | // Write the changes or log them in dry run mode
153 | if (options.dryRun) {
154 | log(
155 | `Would update version in ${filePath} from ${currentVersion} to ${newVersion}`,
156 | );
157 | } else {
158 | // Create a backup of the original file
159 | fs.writeFileSync(`${filePath}.bak`, fileContent);
160 | log(`Backup created: ${filePath}.bak`, true);
161 |
162 | // Write the updated content
163 | fs.writeFileSync(filePath, updatedContent);
164 | log(
165 | `Updated version in ${path.basename(filePath)} from ${currentVersion} to ${newVersion}`,
166 | );
167 | }
168 | } catch (error) {
169 | if (optional) {
170 | log(`Error with optional file ${filePath}: ${error.message}`, true);
171 | return;
172 | }
173 | console.error(`Error updating ${filePath}: ${error.message}`);
174 | process.exit(1);
175 | }
176 | }
177 |
178 | // Main execution
179 | try {
180 | // If no version specified, get from package.json
181 | if (!newVersion) {
182 | newVersion = getPackageVersion();
183 | log(
184 | `No version specified, using version from package.json: ${newVersion}`,
185 | );
186 | }
187 |
188 | // Validate the version format
189 | validateVersion(newVersion);
190 |
191 | // Update all configured files
192 | for (const fileConfig of versionFiles) {
193 | updateFileVersion(fileConfig);
194 | }
195 |
196 | if (options.dryRun) {
197 | log(`\nDry run completed. No files were modified.`);
198 | } else {
199 | log(`\nVersion successfully updated to ${newVersion}`);
200 | }
201 | } catch (error) {
202 | console.error(`\nVersion update failed: ${error.message}`);
203 | process.exit(1);
204 | }
205 |
```
--------------------------------------------------------------------------------
/src/services/vendor.atlassian.workspaces.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import atlassianWorkspacesService from './vendor.atlassian.workspaces.service.js';
2 | import { getAtlassianCredentials } from '../utils/transport.util.js';
3 | import { config } from '../utils/config.util.js';
4 | import { McpError } from '../utils/error.util.js';
5 |
6 | describe('Vendor Atlassian Workspaces Service', () => {
7 | // Load configuration and check for credentials before all tests
8 | beforeAll(() => {
9 | config.load(); // Ensure config is loaded
10 | const credentials = getAtlassianCredentials();
11 | if (!credentials) {
12 | console.warn(
13 | 'Skipping Atlassian Workspaces Service tests: No credentials available',
14 | );
15 | }
16 | });
17 |
18 | // Helper function to skip tests when credentials are missing
19 | const skipIfNoCredentials = () => !getAtlassianCredentials();
20 |
21 | describe('list', () => {
22 | it('should return a list of workspaces (permissions)', async () => {
23 | if (skipIfNoCredentials()) return;
24 |
25 | const result = await atlassianWorkspacesService.list();
26 |
27 | // Verify the response structure based on WorkspacePermissionsResponse
28 | expect(result).toHaveProperty('values');
29 | expect(Array.isArray(result.values)).toBe(true);
30 | expect(result).toHaveProperty('pagelen'); // Bitbucket uses pagelen
31 | expect(result).toHaveProperty('page');
32 | expect(result).toHaveProperty('size');
33 |
34 | if (result.values.length > 0) {
35 | const membership = result.values[0];
36 | expect(membership).toHaveProperty(
37 | 'type',
38 | 'workspace_membership',
39 | );
40 | expect(membership).toHaveProperty('permission');
41 | expect(membership).toHaveProperty('user');
42 | expect(membership).toHaveProperty('workspace');
43 | expect(membership.workspace).toHaveProperty('slug');
44 | expect(membership.workspace).toHaveProperty('uuid');
45 | }
46 | }, 30000); // Increased timeout
47 |
48 | it('should support pagination with pagelen', async () => {
49 | if (skipIfNoCredentials()) return;
50 |
51 | const result = await atlassianWorkspacesService.list({
52 | pagelen: 1,
53 | });
54 |
55 | expect(result).toHaveProperty('pagelen');
56 | // Allow pagelen to be greater than requested if API enforces minimum
57 | expect(result.pagelen).toBeGreaterThanOrEqual(1);
58 | expect(result.values.length).toBeLessThanOrEqual(result.pagelen); // Items should not exceed pagelen
59 |
60 | if (result.size > result.pagelen) {
61 | // If there are more items than the page size, expect pagination links
62 | expect(result).toHaveProperty('next');
63 | }
64 | }, 30000);
65 |
66 | it('should handle query filtering if supported by the API', async () => {
67 | if (skipIfNoCredentials()) return;
68 |
69 | // First get all workspaces to find a potential query term
70 | const allWorkspaces = await atlassianWorkspacesService.list();
71 |
72 | // Skip if no workspaces available
73 | if (allWorkspaces.values.length === 0) {
74 | console.warn(
75 | 'Skipping query filtering test: No workspaces available',
76 | );
77 | return;
78 | }
79 |
80 | // Try to search using a workspace name - note that this might not work if
81 | // the API doesn't fully support 'q' parameter for this endpoint
82 | // This test basically checks that the request doesn't fail
83 | const firstWorkspace = allWorkspaces.values[0].workspace;
84 | try {
85 | const result = await atlassianWorkspacesService.list({
86 | q: `workspace.name="${firstWorkspace.name}"`,
87 | });
88 |
89 | // We're mostly testing that this request completes without error
90 | expect(result).toHaveProperty('values');
91 |
92 | // The result might be empty if filtering isn't supported,
93 | // so we don't assert on the number of results returned
94 | } catch (error) {
95 | // If filtering isn't supported, the API might return an error
96 | // This is acceptable, so we just log it
97 | console.warn(
98 | 'Query filtering test encountered an error:',
99 | error instanceof Error ? error.message : String(error),
100 | );
101 | }
102 | }, 30000);
103 | });
104 |
105 | describe('get', () => {
106 | // Helper to get a valid slug for testing 'get'
107 | async function getFirstWorkspaceSlug(): Promise<string | null> {
108 | if (skipIfNoCredentials()) return null;
109 | try {
110 | const listResult = await atlassianWorkspacesService.list({
111 | pagelen: 1,
112 | });
113 | return listResult.values.length > 0
114 | ? listResult.values[0].workspace.slug
115 | : null;
116 | } catch (error) {
117 | console.warn(
118 | "Could not fetch workspace list for 'get' test setup:",
119 | error,
120 | );
121 | return null;
122 | }
123 | }
124 |
125 | it('should return details for a valid workspace slug', async () => {
126 | const workspaceSlug = await getFirstWorkspaceSlug();
127 | if (!workspaceSlug) {
128 | console.warn('Skipping get test: No workspace slug found.');
129 | return;
130 | }
131 |
132 | const result = await atlassianWorkspacesService.get(workspaceSlug);
133 |
134 | // Verify the response structure based on WorkspaceDetailed
135 | expect(result).toHaveProperty('uuid');
136 | expect(result).toHaveProperty('slug', workspaceSlug);
137 | expect(result).toHaveProperty('name');
138 | expect(result).toHaveProperty('type', 'workspace');
139 | expect(result).toHaveProperty('links');
140 | expect(result.links).toHaveProperty('html');
141 | }, 30000);
142 |
143 | it('should throw an McpError for an invalid workspace slug', async () => {
144 | if (skipIfNoCredentials()) return;
145 |
146 | const invalidSlug = 'this-slug-definitely-does-not-exist-12345';
147 |
148 | // Expect the service call to reject with an McpError (likely 404)
149 | await expect(
150 | atlassianWorkspacesService.get(invalidSlug),
151 | ).rejects.toThrow(McpError);
152 |
153 | // Optionally check the status code if needed
154 | try {
155 | await atlassianWorkspacesService.get(invalidSlug);
156 | } catch (e) {
157 | expect(e).toBeInstanceOf(McpError);
158 | expect((e as McpError).statusCode).toBe(404); // Expecting Not Found
159 | }
160 | }, 30000);
161 | });
162 | });
163 |
```
--------------------------------------------------------------------------------
/src/utils/config.util.ts:
--------------------------------------------------------------------------------
```typescript
1 | import fs from 'fs';
2 | import path from 'path';
3 | import { Logger } from './logger.util.js';
4 | import dotenv from 'dotenv';
5 | import os from 'os';
6 |
7 | /**
8 | * Configuration loader that handles multiple sources with priority:
9 | * 1. Direct ENV pass (process.env)
10 | * 2. .env file in project root
11 | * 3. Global config file at $HOME/.mcp/configs.json
12 | */
13 | class ConfigLoader {
14 | private packageName: string;
15 | private configLoaded: boolean = false;
16 |
17 | /**
18 | * Create a new ConfigLoader instance
19 | * @param packageName The package name to use for global config lookup
20 | */
21 | constructor(packageName: string) {
22 | this.packageName = packageName;
23 | }
24 |
25 | /**
26 | * Load configuration from all sources with proper priority
27 | */
28 | load(): void {
29 | const methodLogger = Logger.forContext('utils/config.util.ts', 'load');
30 | if (this.configLoaded) {
31 | methodLogger.debug('Configuration already loaded, skipping');
32 | return;
33 | }
34 |
35 | methodLogger.debug('Loading configuration...');
36 |
37 | // Priority 3: Load from global config file
38 | this.loadFromGlobalConfig();
39 |
40 | // Priority 2: Load from .env file
41 | this.loadFromEnvFile();
42 |
43 | // Priority 1: Direct ENV pass is already in process.env
44 | // No need to do anything as it already has highest priority
45 |
46 | this.configLoaded = true;
47 | methodLogger.debug('Configuration loaded successfully');
48 | }
49 |
50 | /**
51 | * Load configuration from .env file in project root
52 | */
53 | private loadFromEnvFile(): void {
54 | const methodLogger = Logger.forContext(
55 | 'utils/config.util.ts',
56 | 'loadFromEnvFile',
57 | );
58 | try {
59 | // Use quiet mode to prevent dotenv from outputting to STDIO
60 | // which interferes with MCP's JSON-RPC communication
61 | const result = dotenv.config({ quiet: true });
62 | if (result.error) {
63 | methodLogger.debug('No .env file found or error reading it');
64 | return;
65 | }
66 | methodLogger.debug('Loaded configuration from .env file');
67 | } catch (error) {
68 | methodLogger.error('Error loading .env file', error);
69 | }
70 | }
71 |
72 | /**
73 | * Load configuration from global config file at $HOME/.mcp/configs.json
74 | */
75 | private loadFromGlobalConfig(): void {
76 | const methodLogger = Logger.forContext(
77 | 'utils/config.util.ts',
78 | 'loadFromGlobalConfig',
79 | );
80 | try {
81 | const homedir = os.homedir();
82 | const globalConfigPath = path.join(homedir, '.mcp', 'configs.json');
83 |
84 | if (!fs.existsSync(globalConfigPath)) {
85 | methodLogger.debug('Global config file not found');
86 | return;
87 | }
88 |
89 | const configContent = fs.readFileSync(globalConfigPath, 'utf8');
90 | const config = JSON.parse(configContent);
91 |
92 | // Determine the potential keys for the current package
93 | const shortKey = 'bitbucket'; // Project-specific short key
94 | const atlassianProductKey = 'atlassian-bitbucket'; // New supported key
95 | const fullPackageName = this.packageName; // e.g., '@aashari/mcp-server-atlassian-bitbucket'
96 | const unscopedPackageName =
97 | fullPackageName.split('/')[1] || fullPackageName; // e.g., 'mcp-server-atlassian-bitbucket'
98 |
99 | // Define the prioritized order of keys to check
100 | const potentialKeys = [
101 | shortKey,
102 | atlassianProductKey,
103 | fullPackageName,
104 | unscopedPackageName,
105 | ];
106 | let foundConfigSection: {
107 | environments?: Record<string, unknown>;
108 | } | null = null;
109 | let usedKey: string | null = null;
110 |
111 | for (const key of potentialKeys) {
112 | if (
113 | config[key] &&
114 | typeof config[key] === 'object' &&
115 | config[key].environments
116 | ) {
117 | foundConfigSection = config[key];
118 | usedKey = key;
119 | methodLogger.debug(`Found configuration using key: ${key}`);
120 | break; // Stop once found
121 | }
122 | }
123 |
124 | if (!foundConfigSection || !foundConfigSection.environments) {
125 | methodLogger.debug(
126 | `No configuration found for ${
127 | this.packageName
128 | } using keys: ${potentialKeys.join(', ')}`,
129 | );
130 | return;
131 | }
132 |
133 | const environments = foundConfigSection.environments;
134 | for (const [key, value] of Object.entries(environments)) {
135 | // Only set if not already defined in process.env
136 | if (process.env[key] === undefined) {
137 | process.env[key] = String(value);
138 | }
139 | }
140 |
141 | methodLogger.debug(
142 | `Loaded configuration from global config file using key: ${usedKey}`,
143 | );
144 | } catch (error) {
145 | methodLogger.error('Error loading global config file', error);
146 | }
147 | }
148 |
149 | /**
150 | * Get a configuration value
151 | * @param key The configuration key
152 | * @param defaultValue The default value if the key is not found
153 | * @returns The configuration value or the default value
154 | */
155 | get(key: string, defaultValue?: string): string | undefined {
156 | return process.env[key] || defaultValue;
157 | }
158 |
159 | /**
160 | * Get a boolean configuration value
161 | * @param key The configuration key
162 | * @param defaultValue The default value if the key is not found
163 | * @returns The boolean configuration value or the default value
164 | */
165 | getBoolean(key: string, defaultValue: boolean = false): boolean {
166 | const value = this.get(key);
167 | if (value === undefined) {
168 | return defaultValue;
169 | }
170 | return value.toLowerCase() === 'true';
171 | }
172 |
173 | /**
174 | * Get a number configuration value
175 | * @param key The configuration key
176 | * @param defaultValue The default value if the key is not found
177 | * @returns The number configuration value or the default value
178 | */
179 | getNumber(key: string, defaultValue: number = 0): number {
180 | const value = this.get(key);
181 | if (value === undefined) {
182 | return defaultValue;
183 | }
184 | const parsed = parseInt(value, 10);
185 | return isNaN(parsed) ? defaultValue : parsed;
186 | }
187 | }
188 |
189 | // Create and export a singleton instance with the package name from package.json
190 | export const config = new ConfigLoader(
191 | '@aashari/mcp-server-atlassian-bitbucket',
192 | );
193 |
```
--------------------------------------------------------------------------------
/STYLE_GUIDE.md:
--------------------------------------------------------------------------------
```markdown
1 | # MCP Server Style Guide
2 |
3 | Based on the MCP SDK v1.22.0+ best practices and observed patterns, this guide ensures consistency across all MCP servers.
4 |
5 | ## Naming Conventions
6 |
7 | | Element | Convention | Rationale / Examples |
8 | | :------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------ |
9 | | **CLI Commands** | `verb-noun` in `kebab-case`. Use the shortest unambiguous verb (`ls`, `get`, `create`, `add`, `exec`, `search`). | `ls-repos`, `get-pr`, `create-comment`, `exec-command` |
10 | | **CLI Options** | `--kebab-case`. Be specific (e.g., `--workspace-slug`, not just `--slug`). | `--project-key-or-id`, `--source-branch` |
11 | | **MCP Tool Names** | `<namespace>_<verb>_<noun>` in `snake_case`. Use a concise 2-4 char namespace. Avoid noun repetition. | `bb_ls_repos` (Bitbucket list repos), `conf_get_page` (Confluence get page), `aws_exec_command` (AWS execute command). Avoid `ip_ip_get_details`. |
12 | | **MCP Resource Names**| `kebab-case`. Descriptive identifier for the resource type. | `ip-lookup`, `user-profile`, `config-data` |
13 | | **MCP Arguments** | `camelCase`. Suffix identifiers consistently (e.g., `Id`, `Key`, `Slug`). Avoid abbreviations unless universal. | `workspaceSlug`, `pullRequestId`, `sourceBranch`, `pageId`. |
14 | | **Boolean Args** | Use verb prefixes for clarity (`includeXxx`, `launchBrowser`). Avoid bare adjectives (`--https`). | `includeExtendedData: boolean`, `launchBrowser: boolean` |
15 | | **Array Args** | Use plural names (`spaceIds`, `labels`, `statuses`). | `spaceIds: string[]`, `labels: string[]` |
16 | | **Descriptions** | **Start with an imperative verb.** Keep the first sentence concise (≤120 chars). Add 1-2 sentences detail. Mention pre-requisites/notes last. | `List available Confluence spaces. Filters by type, status, or query. Returns formatted list including ID, key, name.` |
17 | | **Arg Descriptions** | Start lowercase, explain purpose clearly. Mention defaults or constraints. | `numeric ID of the page to retrieve (e.g., "456789"). Required.` |
18 | | **ID/Key Naming** | Use consistent suffixes like `Id`, `Key`, `Slug`, `KeyOrId` where appropriate. | `pageId`, `projectKeyOrId`, `workspaceSlug` |
19 |
20 | ## SDK Best Practices (v1.22.0+)
21 |
22 | ### Title vs Name
23 |
24 | All registrations (`registerTool`, `registerResource`, `registerPrompt`) support both `name` and `title`:
25 |
26 | | Field | Purpose | Example |
27 | | :---- | :------ | :------ |
28 | | `name` | Unique identifier for programmatic use | `bb_get` |
29 | | `title` | Human-readable display name for UI | `Bitbucket GET Request` |
30 |
31 | **Always provide both** - `name` for code, `title` for user interfaces.
32 |
33 | ### Modern Registration APIs
34 |
35 | Use the modern `register*` methods instead of deprecated alternatives:
36 |
37 | | Deprecated | Modern (SDK v1.22.0+) |
38 | | :--------- | :-------------------- |
39 | | `server.tool()` | `server.registerTool()` |
40 | | `server.resource()` | `server.registerResource()` |
41 | | `server.prompt()` | `server.registerPrompt()` |
42 |
43 | ### Resource Templates
44 |
45 | Use `ResourceTemplate` for parameterized resource URIs:
46 |
47 | ```typescript
48 | import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
49 |
50 | // Static resource - fixed URI
51 | server.registerResource('config', 'config://app', { ... }, handler);
52 |
53 | // Dynamic resource - parameterized URI
54 | server.registerResource(
55 | 'user-profile',
56 | new ResourceTemplate('users://{userId}/profile', { list: undefined }),
57 | { title: 'User Profile', description: '...' },
58 | async (uri, variables) => {
59 | const userId = variables.userId as string;
60 | // ...
61 | }
62 | );
63 | ```
64 |
65 | ### Error Handling
66 |
67 | Use `isError: true` for tool execution failures:
68 |
69 | ```typescript
70 | return {
71 | content: [{ type: 'text', text: 'Error: Something went wrong' }],
72 | isError: true
73 | };
74 | ```
75 |
76 | Adopting this guide will make the tools more predictable and easier for both humans and AI agents to understand and use correctly.
77 |
```
--------------------------------------------------------------------------------
/src/controllers/atlassian.api.controller.ts:
--------------------------------------------------------------------------------
```typescript
1 | import {
2 | fetchAtlassian,
3 | getAtlassianCredentials,
4 | } from '../utils/transport.util.js';
5 | import { Logger } from '../utils/logger.util.js';
6 | import { handleControllerError } from '../utils/error-handler.util.js';
7 | import { ControllerResponse } from '../types/common.types.js';
8 | import {
9 | GetApiToolArgsType,
10 | RequestWithBodyArgsType,
11 | } from '../tools/atlassian.api.types.js';
12 | import { applyJqFilter, toOutputString } from '../utils/jq.util.js';
13 | import { createAuthMissingError } from '../utils/error.util.js';
14 |
15 | // Logger instance for this module
16 | const logger = Logger.forContext('controllers/atlassian.api.controller.ts');
17 |
18 | /**
19 | * Supported HTTP methods for API requests
20 | */
21 | type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
22 |
23 | /**
24 | * Output format type
25 | */
26 | type OutputFormat = 'toon' | 'json';
27 |
28 | /**
29 | * Base options for all API requests
30 | */
31 | interface BaseRequestOptions {
32 | path: string;
33 | queryParams?: Record<string, string>;
34 | jq?: string;
35 | outputFormat?: OutputFormat;
36 | }
37 |
38 | /**
39 | * Options for requests that include a body (POST, PUT, PATCH)
40 | */
41 | interface RequestWithBodyOptions extends BaseRequestOptions {
42 | body?: Record<string, unknown>;
43 | }
44 |
45 | /**
46 | * Normalizes the API path by ensuring it starts with /2.0
47 | * @param path - The raw path provided by the user
48 | * @returns Normalized path with /2.0 prefix
49 | */
50 | function normalizePath(path: string): string {
51 | let normalizedPath = path;
52 | if (!normalizedPath.startsWith('/')) {
53 | normalizedPath = '/' + normalizedPath;
54 | }
55 | if (!normalizedPath.startsWith('/2.0')) {
56 | normalizedPath = '/2.0' + normalizedPath;
57 | }
58 | return normalizedPath;
59 | }
60 |
61 | /**
62 | * Appends query parameters to a path
63 | * @param path - The base path
64 | * @param queryParams - Optional query parameters
65 | * @returns Path with query string appended
66 | */
67 | function appendQueryParams(
68 | path: string,
69 | queryParams?: Record<string, string>,
70 | ): string {
71 | if (!queryParams || Object.keys(queryParams).length === 0) {
72 | return path;
73 | }
74 | const queryString = new URLSearchParams(queryParams).toString();
75 | return path + (path.includes('?') ? '&' : '?') + queryString;
76 | }
77 |
78 | /**
79 | * Shared handler for all HTTP methods
80 | *
81 | * @param method - HTTP method (GET, POST, PUT, PATCH, DELETE)
82 | * @param options - Request options including path, queryParams, body (for non-GET), and jq filter
83 | * @returns Promise with raw JSON response (optionally filtered)
84 | */
85 | async function handleRequest(
86 | method: HttpMethod,
87 | options: RequestWithBodyOptions,
88 | ): Promise<ControllerResponse> {
89 | const methodLogger = logger.forMethod(`handle${method}`);
90 |
91 | try {
92 | methodLogger.debug(`Making ${method} request`, {
93 | path: options.path,
94 | ...(options.body && { bodyKeys: Object.keys(options.body) }),
95 | });
96 |
97 | // Get credentials
98 | const credentials = getAtlassianCredentials();
99 | if (!credentials) {
100 | throw createAuthMissingError();
101 | }
102 |
103 | // Normalize path and append query params
104 | let path = normalizePath(options.path);
105 | path = appendQueryParams(path, options.queryParams);
106 |
107 | methodLogger.debug(`${method}ing: ${path}`);
108 |
109 | const fetchOptions: {
110 | method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
111 | body?: Record<string, unknown>;
112 | } = {
113 | method,
114 | };
115 |
116 | // Add body for methods that support it
117 | if (options.body && ['POST', 'PUT', 'PATCH'].includes(method)) {
118 | fetchOptions.body = options.body;
119 | }
120 |
121 | const response = await fetchAtlassian<unknown>(
122 | credentials,
123 | path,
124 | fetchOptions,
125 | );
126 | methodLogger.debug('Successfully received response');
127 |
128 | // Apply JQ filter if provided, otherwise return raw data
129 | const result = applyJqFilter(response.data, options.jq);
130 |
131 | // Convert to output format (TOON by default, JSON if requested)
132 | const useToon = options.outputFormat !== 'json';
133 | const content = await toOutputString(result, useToon);
134 |
135 | return {
136 | content,
137 | rawResponsePath: response.rawResponsePath,
138 | };
139 | } catch (error) {
140 | throw handleControllerError(error, {
141 | entityType: 'API',
142 | operation: `${method} request`,
143 | source: `controllers/atlassian.api.controller.ts@handle${method}`,
144 | additionalInfo: { path: options.path },
145 | });
146 | }
147 | }
148 |
149 | /**
150 | * Generic GET request to Bitbucket API
151 | *
152 | * @param options - Options containing path, queryParams, and optional jq filter
153 | * @returns Promise with raw JSON response (optionally filtered)
154 | */
155 | export async function handleGet(
156 | options: GetApiToolArgsType,
157 | ): Promise<ControllerResponse> {
158 | return handleRequest('GET', options);
159 | }
160 |
161 | /**
162 | * Generic POST request to Bitbucket API
163 | *
164 | * @param options - Options containing path, body, queryParams, and optional jq filter
165 | * @returns Promise with raw JSON response (optionally filtered)
166 | */
167 | export async function handlePost(
168 | options: RequestWithBodyArgsType,
169 | ): Promise<ControllerResponse> {
170 | return handleRequest('POST', options);
171 | }
172 |
173 | /**
174 | * Generic PUT request to Bitbucket API
175 | *
176 | * @param options - Options containing path, body, queryParams, and optional jq filter
177 | * @returns Promise with raw JSON response (optionally filtered)
178 | */
179 | export async function handlePut(
180 | options: RequestWithBodyArgsType,
181 | ): Promise<ControllerResponse> {
182 | return handleRequest('PUT', options);
183 | }
184 |
185 | /**
186 | * Generic PATCH request to Bitbucket API
187 | *
188 | * @param options - Options containing path, body, queryParams, and optional jq filter
189 | * @returns Promise with raw JSON response (optionally filtered)
190 | */
191 | export async function handlePatch(
192 | options: RequestWithBodyArgsType,
193 | ): Promise<ControllerResponse> {
194 | return handleRequest('PATCH', options);
195 | }
196 |
197 | /**
198 | * Generic DELETE request to Bitbucket API
199 | *
200 | * @param options - Options containing path, queryParams, and optional jq filter
201 | * @returns Promise with raw JSON response (optionally filtered)
202 | */
203 | export async function handleDelete(
204 | options: GetApiToolArgsType,
205 | ): Promise<ControllerResponse> {
206 | return handleRequest('DELETE', options);
207 | }
208 |
```
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | #!/usr/bin/env node
2 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3 | import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
4 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5 | import { Logger } from './utils/logger.util.js';
6 | import { config } from './utils/config.util.js';
7 | import { VERSION, PACKAGE_NAME } from './utils/constants.util.js';
8 | import { runCli } from './cli/index.js';
9 | import type { Request, Response } from 'express';
10 | import express from 'express';
11 | import cors from 'cors';
12 |
13 | // Import tools
14 | import atlassianApi from './tools/atlassian.api.tool.js';
15 | import atlassianRepositories from './tools/atlassian.repositories.tool.js';
16 |
17 | // Create a contextualized logger for this file
18 | const indexLogger = Logger.forContext('index.ts');
19 |
20 | // Log initialization at debug level
21 | indexLogger.debug('Bitbucket MCP server module loaded');
22 |
23 | let serverInstance: McpServer | null = null;
24 | let transportInstance:
25 | | StreamableHTTPServerTransport
26 | | StdioServerTransport
27 | | null = null;
28 |
29 | /**
30 | * Start the MCP server with the specified transport mode
31 | *
32 | * @param mode The transport mode to use (stdio or http)
33 | * @returns Promise that resolves to the server instance when started successfully
34 | */
35 | export async function startServer(
36 | mode: 'stdio' | 'http' = 'stdio',
37 | ): Promise<McpServer> {
38 | const serverLogger = Logger.forContext('index.ts', 'startServer');
39 |
40 | // Load configuration
41 | serverLogger.info('Starting MCP server initialization...');
42 | config.load();
43 |
44 | if (config.getBoolean('DEBUG')) {
45 | serverLogger.debug('Debug mode enabled');
46 | }
47 |
48 | serverLogger.info(`Initializing Bitbucket MCP server v${VERSION}`);
49 | serverInstance = new McpServer({
50 | name: PACKAGE_NAME,
51 | version: VERSION,
52 | });
53 |
54 | // Register all tools
55 | serverLogger.info('Registering MCP tools...');
56 | atlassianApi.registerTools(serverInstance);
57 | atlassianRepositories.registerTools(serverInstance);
58 | serverLogger.info('All tools registered successfully');
59 |
60 | if (mode === 'stdio') {
61 | // STDIO Transport
62 | serverLogger.info('Using STDIO transport for MCP communication');
63 | transportInstance = new StdioServerTransport();
64 |
65 | try {
66 | await serverInstance.connect(transportInstance);
67 | serverLogger.info(
68 | 'MCP server started successfully on STDIO transport',
69 | );
70 | setupGracefulShutdown();
71 | return serverInstance;
72 | } catch (err) {
73 | serverLogger.error(
74 | 'Failed to start server on STDIO transport',
75 | err,
76 | );
77 | process.exit(1);
78 | }
79 | } else {
80 | // HTTP Transport with Express
81 | serverLogger.info(
82 | 'Using Streamable HTTP transport for MCP communication',
83 | );
84 |
85 | const app = express();
86 | app.use(cors());
87 | app.use(express.json());
88 |
89 | const mcpEndpoint = '/mcp';
90 | serverLogger.debug(`MCP endpoint: ${mcpEndpoint}`);
91 |
92 | // Create transport instance
93 | const transport = new StreamableHTTPServerTransport({
94 | sessionIdGenerator: undefined,
95 | });
96 |
97 | // Connect server to transport
98 | await serverInstance.connect(transport);
99 | transportInstance = transport;
100 |
101 | // Handle all MCP requests
102 | app.all(mcpEndpoint, (req: Request, res: Response) => {
103 | transport
104 | .handleRequest(req, res, req.body)
105 | .catch((err: unknown) => {
106 | serverLogger.error('Error in transport.handleRequest', err);
107 | if (!res.headersSent) {
108 | res.status(500).json({
109 | error: 'Internal Server Error',
110 | });
111 | }
112 | });
113 | });
114 |
115 | // Health check endpoint
116 | app.get('/', (_req: Request, res: Response) => {
117 | res.send(`Bitbucket MCP Server v${VERSION} is running`);
118 | });
119 |
120 | // Start HTTP server
121 | const PORT = Number(process.env.PORT ?? 3000);
122 | await new Promise<void>((resolve) => {
123 | app.listen(PORT, () => {
124 | serverLogger.info(
125 | `HTTP transport listening on http://localhost:${PORT}${mcpEndpoint}`,
126 | );
127 | resolve();
128 | });
129 | });
130 |
131 | setupGracefulShutdown();
132 | return serverInstance;
133 | }
134 | }
135 |
136 | /**
137 | * Main entry point - this will run when executed directly
138 | * Determines whether to run in CLI or server mode based on command-line arguments
139 | */
140 | async function main() {
141 | const mainLogger = Logger.forContext('index.ts', 'main');
142 |
143 | // Load configuration
144 | config.load();
145 |
146 | // CLI mode - if any arguments are provided
147 | if (process.argv.length > 2) {
148 | mainLogger.info('Starting in CLI mode');
149 | await runCli(process.argv.slice(2));
150 | mainLogger.info('CLI execution completed');
151 | return;
152 | }
153 |
154 | // Server mode - determine transport
155 | const transportMode = (process.env.TRANSPORT_MODE || 'stdio').toLowerCase();
156 | let mode: 'http' | 'stdio';
157 |
158 | if (transportMode === 'stdio') {
159 | mode = 'stdio';
160 | } else if (transportMode === 'http') {
161 | mode = 'http';
162 | } else {
163 | mainLogger.warn(
164 | `Unknown TRANSPORT_MODE "${transportMode}", defaulting to stdio`,
165 | );
166 | mode = 'stdio';
167 | }
168 |
169 | mainLogger.info(`Starting server with ${mode.toUpperCase()} transport`);
170 | await startServer(mode);
171 | mainLogger.info('Server is now running');
172 | }
173 |
174 | /**
175 | * Set up graceful shutdown handlers for the server
176 | */
177 | function setupGracefulShutdown() {
178 | const shutdownLogger = Logger.forContext('index.ts', 'shutdown');
179 |
180 | const shutdown = async () => {
181 | try {
182 | shutdownLogger.info('Shutting down gracefully...');
183 |
184 | if (
185 | transportInstance &&
186 | 'close' in transportInstance &&
187 | typeof transportInstance.close === 'function'
188 | ) {
189 | await transportInstance.close();
190 | }
191 |
192 | if (serverInstance && typeof serverInstance.close === 'function') {
193 | await serverInstance.close();
194 | }
195 |
196 | process.exit(0);
197 | } catch (err) {
198 | shutdownLogger.error('Error during shutdown', err);
199 | process.exit(1);
200 | }
201 | };
202 |
203 | ['SIGINT', 'SIGTERM'].forEach((signal) => {
204 | process.on(signal as NodeJS.Signals, shutdown);
205 | });
206 | }
207 |
208 | // If this file is being executed directly (not imported), run the main function
209 | if (require.main === module) {
210 | main().catch((err) => {
211 | indexLogger.error('Unhandled error in main process', err);
212 | process.exit(1);
213 | });
214 | }
215 |
```
--------------------------------------------------------------------------------
/src/cli/atlassian.api.cli.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Command } from 'commander';
2 | import { Logger } from '../utils/logger.util.js';
3 | import { handleCliError } from '../utils/error.util.js';
4 | import {
5 | handleGet,
6 | handlePost,
7 | handlePut,
8 | handlePatch,
9 | handleDelete,
10 | } from '../controllers/atlassian.api.controller.js';
11 |
12 | /**
13 | * CLI module for generic Bitbucket API access.
14 | * Provides commands for making GET, POST, PUT, PATCH, and DELETE requests to any Bitbucket API endpoint.
15 | */
16 |
17 | // Create a contextualized logger for this file
18 | const cliLogger = Logger.forContext('cli/atlassian.api.cli.ts');
19 |
20 | // Log CLI initialization
21 | cliLogger.debug('Bitbucket API CLI module initialized');
22 |
23 | /**
24 | * Parse JSON string with error handling and basic validation
25 | * @param jsonString - JSON string to parse
26 | * @param fieldName - Name of the field for error messages
27 | * @returns Parsed JSON object
28 | */
29 | function parseJson<T extends Record<string, unknown>>(
30 | jsonString: string,
31 | fieldName: string,
32 | ): T {
33 | let parsed: unknown;
34 | try {
35 | parsed = JSON.parse(jsonString);
36 | } catch {
37 | throw new Error(
38 | `Invalid JSON in --${fieldName}. Please provide valid JSON.`,
39 | );
40 | }
41 |
42 | // Validate that the parsed value is an object (not null, array, or primitive)
43 | if (
44 | parsed === null ||
45 | typeof parsed !== 'object' ||
46 | Array.isArray(parsed)
47 | ) {
48 | throw new Error(
49 | `Invalid --${fieldName}: expected a JSON object, got ${parsed === null ? 'null' : Array.isArray(parsed) ? 'array' : typeof parsed}.`,
50 | );
51 | }
52 |
53 | return parsed as T;
54 | }
55 |
56 | /**
57 | * Register a read command (GET/DELETE - no body)
58 | * @param program - Commander program instance
59 | * @param name - Command name
60 | * @param description - Command description
61 | * @param handler - Controller handler function
62 | */
63 | function registerReadCommand(
64 | program: Command,
65 | name: string,
66 | description: string,
67 | handler: (options: {
68 | path: string;
69 | queryParams?: Record<string, string>;
70 | jq?: string;
71 | }) => Promise<{ content: string }>,
72 | ): void {
73 | program
74 | .command(name)
75 | .description(description)
76 | .requiredOption(
77 | '-p, --path <path>',
78 | 'API endpoint path (e.g., "/workspaces", "/repositories/{workspace}/{repo}").',
79 | )
80 | .option(
81 | '-q, --query-params <json>',
82 | 'Query parameters as JSON string (e.g., \'{"pagelen": "25"}\').',
83 | )
84 | .option(
85 | '--jq <expression>',
86 | 'JMESPath expression to filter/transform the response.',
87 | )
88 | .action(async (options) => {
89 | const actionLogger = cliLogger.forMethod(name);
90 | try {
91 | actionLogger.debug(`CLI ${name} called`, options);
92 |
93 | // Parse query params if provided
94 | let queryParams: Record<string, string> | undefined;
95 | if (options.queryParams) {
96 | queryParams = parseJson<Record<string, string>>(
97 | options.queryParams,
98 | 'query-params',
99 | );
100 | }
101 |
102 | const result = await handler({
103 | path: options.path,
104 | queryParams,
105 | jq: options.jq,
106 | });
107 |
108 | console.log(result.content);
109 | } catch (error) {
110 | handleCliError(error);
111 | }
112 | });
113 | }
114 |
115 | /**
116 | * Register a write command (POST/PUT/PATCH - with body)
117 | * @param program - Commander program instance
118 | * @param name - Command name
119 | * @param description - Command description
120 | * @param handler - Controller handler function
121 | */
122 | function registerWriteCommand(
123 | program: Command,
124 | name: string,
125 | description: string,
126 | handler: (options: {
127 | path: string;
128 | body: Record<string, unknown>;
129 | queryParams?: Record<string, string>;
130 | jq?: string;
131 | }) => Promise<{ content: string }>,
132 | ): void {
133 | program
134 | .command(name)
135 | .description(description)
136 | .requiredOption(
137 | '-p, --path <path>',
138 | 'API endpoint path (e.g., "/repositories/{workspace}/{repo}/pullrequests").',
139 | )
140 | .requiredOption('-b, --body <json>', 'Request body as JSON string.')
141 | .option('-q, --query-params <json>', 'Query parameters as JSON string.')
142 | .option(
143 | '--jq <expression>',
144 | 'JMESPath expression to filter/transform the response.',
145 | )
146 | .action(async (options) => {
147 | const actionLogger = cliLogger.forMethod(name);
148 | try {
149 | actionLogger.debug(`CLI ${name} called`, options);
150 |
151 | // Parse body
152 | const body = parseJson<Record<string, unknown>>(
153 | options.body,
154 | 'body',
155 | );
156 |
157 | // Parse query params if provided
158 | let queryParams: Record<string, string> | undefined;
159 | if (options.queryParams) {
160 | queryParams = parseJson<Record<string, string>>(
161 | options.queryParams,
162 | 'query-params',
163 | );
164 | }
165 |
166 | const result = await handler({
167 | path: options.path,
168 | body,
169 | queryParams,
170 | jq: options.jq,
171 | });
172 |
173 | console.log(result.content);
174 | } catch (error) {
175 | handleCliError(error);
176 | }
177 | });
178 | }
179 |
180 | /**
181 | * Register generic Bitbucket API CLI commands with the Commander program
182 | *
183 | * @param program - The Commander program instance to register commands with
184 | */
185 | function register(program: Command): void {
186 | const methodLogger = Logger.forContext(
187 | 'cli/atlassian.api.cli.ts',
188 | 'register',
189 | );
190 | methodLogger.debug('Registering Bitbucket API CLI commands...');
191 |
192 | // Register GET command
193 | registerReadCommand(
194 | program,
195 | 'get',
196 | 'GET any Bitbucket endpoint. Returns JSON, optionally filtered with JMESPath.',
197 | handleGet,
198 | );
199 |
200 | // Register POST command
201 | registerWriteCommand(
202 | program,
203 | 'post',
204 | 'POST to any Bitbucket endpoint. Returns JSON, optionally filtered with JMESPath.',
205 | handlePost,
206 | );
207 |
208 | // Register PUT command
209 | registerWriteCommand(
210 | program,
211 | 'put',
212 | 'PUT to any Bitbucket endpoint. Returns JSON, optionally filtered with JMESPath.',
213 | handlePut,
214 | );
215 |
216 | // Register PATCH command
217 | registerWriteCommand(
218 | program,
219 | 'patch',
220 | 'PATCH any Bitbucket endpoint. Returns JSON, optionally filtered with JMESPath.',
221 | handlePatch,
222 | );
223 |
224 | // Register DELETE command
225 | registerReadCommand(
226 | program,
227 | 'delete',
228 | 'DELETE any Bitbucket endpoint. Returns JSON (if any), optionally filtered with JMESPath.',
229 | handleDelete,
230 | );
231 |
232 | methodLogger.debug('CLI commands registered successfully');
233 | }
234 |
235 | export default { register };
236 |
```
--------------------------------------------------------------------------------
/src/utils/transport.util.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { getAtlassianCredentials, fetchAtlassian } from './transport.util.js';
2 | import { config } from './config.util.js';
3 |
4 | /**
5 | * Generic response type for testing
6 | */
7 | interface TestResponse {
8 | values: Array<Record<string, unknown>>;
9 | next?: string;
10 | total?: number;
11 | }
12 |
13 | // NOTE: We are no longer mocking fetch or logger, using real implementations instead
14 |
15 | describe('Transport Utility', () => {
16 | // Load configuration before all tests
17 | beforeAll(() => {
18 | // Load configuration from all sources
19 | config.load();
20 | });
21 |
22 | describe('getAtlassianCredentials', () => {
23 | it('should return credentials when environment variables are set', () => {
24 | // This test will be skipped if credentials are not available
25 | const credentials = getAtlassianCredentials();
26 | if (!credentials) {
27 | return; // Skip silently - no credentials available for testing
28 | }
29 |
30 | // Check if the credentials are for standard Atlassian or Bitbucket-specific
31 | if (credentials.useBitbucketAuth) {
32 | // Verify the Bitbucket-specific credentials
33 | expect(credentials).toHaveProperty('bitbucketUsername');
34 | expect(credentials).toHaveProperty('bitbucketAppPassword');
35 | expect(credentials).toHaveProperty('useBitbucketAuth');
36 |
37 | // Verify the credentials are not empty
38 | expect(credentials.bitbucketUsername).toBeTruthy();
39 | expect(credentials.bitbucketAppPassword).toBeTruthy();
40 | expect(credentials.useBitbucketAuth).toBe(true);
41 | } else {
42 | // Verify the standard Atlassian credentials
43 | expect(credentials).toHaveProperty('userEmail');
44 | expect(credentials).toHaveProperty('apiToken');
45 |
46 | // Verify the credentials are not empty
47 | expect(credentials.userEmail).toBeTruthy();
48 | expect(credentials.apiToken).toBeTruthy();
49 | // Note: siteName is optional for API tokens
50 | }
51 | });
52 |
53 | it('should return null and log a warning when environment variables are missing', () => {
54 | // Store original environment variables
55 | const originalEnv = { ...process.env };
56 |
57 | // Clear relevant environment variables to simulate missing credentials
58 | delete process.env.ATLASSIAN_SITE_NAME;
59 | delete process.env.ATLASSIAN_USER_EMAIL;
60 | delete process.env.ATLASSIAN_API_TOKEN;
61 | delete process.env.ATLASSIAN_BITBUCKET_USERNAME;
62 | delete process.env.ATLASSIAN_BITBUCKET_APP_PASSWORD;
63 |
64 | // Force reload configuration
65 | config.load();
66 |
67 | // Call the function
68 | const credentials = getAtlassianCredentials();
69 |
70 | // Verify the result is null
71 | expect(credentials).toBeNull();
72 |
73 | // Restore original environment
74 | process.env = originalEnv;
75 |
76 | // Reload config with original environment
77 | config.load();
78 | });
79 | });
80 |
81 | describe('fetchAtlassian', () => {
82 | it('should successfully fetch data from the Atlassian API', async () => {
83 | // This test will be skipped if credentials are not available
84 | const credentials = getAtlassianCredentials();
85 | if (!credentials) {
86 | return; // Skip silently - no credentials available for testing
87 | }
88 |
89 | // Make a call to a real API endpoint
90 | // For Bitbucket, we'll use the workspaces endpoint
91 | const result = await fetchAtlassian<TestResponse>(
92 | credentials,
93 | '/2.0/workspaces',
94 | {
95 | method: 'GET',
96 | headers: {
97 | 'Content-Type': 'application/json',
98 | },
99 | },
100 | );
101 |
102 | // Verify the response structure from real API
103 | expect(result.data).toHaveProperty('values');
104 | expect(Array.isArray(result.data.values)).toBe(true);
105 | // Different property names than mocked data to match actual API response
106 | if (result.data.values.length > 0) {
107 | // Verify an actual workspace result
108 | const workspace = result.data.values[0];
109 | expect(workspace).toHaveProperty('uuid');
110 | expect(workspace).toHaveProperty('name');
111 | expect(workspace).toHaveProperty('slug');
112 | }
113 | }, 15000); // Increased timeout for real API call
114 |
115 | it('should handle API errors correctly', async () => {
116 | // This test will be skipped if credentials are not available
117 | const credentials = getAtlassianCredentials();
118 | if (!credentials) {
119 | return; // Skip silently - no credentials available for testing
120 | }
121 |
122 | // Call a non-existent endpoint and expect it to throw
123 | await expect(
124 | fetchAtlassian(credentials, '/2.0/non-existent-endpoint'),
125 | ).rejects.toThrow();
126 | }, 15000); // Increased timeout for real API call
127 |
128 | it('should normalize paths that do not start with a slash', async () => {
129 | // This test will be skipped if credentials are not available
130 | const credentials = getAtlassianCredentials();
131 | if (!credentials) {
132 | return; // Skip silently - no credentials available for testing
133 | }
134 |
135 | // Call the function with a path that doesn't start with a slash
136 | const result = await fetchAtlassian<TestResponse>(
137 | credentials,
138 | '2.0/workspaces',
139 | {
140 | method: 'GET',
141 | },
142 | );
143 |
144 | // Verify the response structure from real API
145 | expect(result.data).toHaveProperty('values');
146 | expect(Array.isArray(result.data.values)).toBe(true);
147 | }, 15000); // Increased timeout for real API call
148 |
149 | it('should support custom request options', async () => {
150 | // This test will be skipped if credentials are not available
151 | const credentials = getAtlassianCredentials();
152 | if (!credentials) {
153 | return; // Skip silently - no credentials available for testing
154 | }
155 |
156 | // Custom request options with pagination
157 | const options = {
158 | method: 'GET' as const,
159 | headers: {
160 | Accept: 'application/json',
161 | 'Content-Type': 'application/json',
162 | },
163 | };
164 |
165 | // Call a real endpoint with pagination parameter
166 | const result = await fetchAtlassian<TestResponse>(
167 | credentials,
168 | '/2.0/workspaces?pagelen=1',
169 | options,
170 | );
171 |
172 | // Verify the response structure from real API
173 | expect(result.data).toHaveProperty('values');
174 | expect(Array.isArray(result.data.values)).toBe(true);
175 | expect(result.data.values.length).toBeLessThanOrEqual(1); // Should respect pagelen=1
176 | }, 15000); // Increased timeout for real API call
177 | });
178 | });
179 |
```