@stubbedev/atlassian-mcp 0.4.5 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js DELETED
@@ -1,1055 +0,0 @@
1
- #!/usr/bin/env node
2
- import 'dotenv/config';
3
- import { execFileSync } from 'child_process';
4
- import { readFileSync } from 'fs';
5
- import { fileURLToPath } from 'url';
6
- import { dirname, join } from 'path';
7
- import { Server } from '@modelcontextprotocol/sdk/server/index.js';
8
- const _pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../package.json'), 'utf-8'));
9
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
10
- import { CallToolRequestSchema, ListToolsRequestSchema, McpError, ErrorCode, } from '@modelcontextprotocol/sdk/types.js';
11
- import { loadConfig } from './config.js';
12
- import { JiraClient } from './jira.js';
13
- import { BitbucketClient, parseBitbucketRemote } from './bitbucket.js';
14
- import { getContext, getDiff, createBranch, checkRemoteBranch, checkoutRemoteBranch } from './git.js';
15
- import { getDevContext, getTopCommitters } from './context.js';
16
- function currentGitRemote() {
17
- try {
18
- return execFileSync('git', ['remote', 'get-url', 'origin'], { encoding: 'utf-8' }).trim();
19
- }
20
- catch {
21
- return '';
22
- }
23
- }
24
- function currentGitBranch() {
25
- try {
26
- return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { encoding: 'utf-8' }).trim();
27
- }
28
- catch {
29
- return '';
30
- }
31
- }
32
- function remoteMatchesBitbucketInstance(remote, bitbucketUrl) {
33
- if (!remote)
34
- return false;
35
- try {
36
- const host = new URL(bitbucketUrl).hostname.toLowerCase();
37
- return remote.toLowerCase().includes(host);
38
- }
39
- catch {
40
- return false;
41
- }
42
- }
43
- const config = loadConfig();
44
- const jira = config.jira ? new JiraClient(config.jira.url, config.jira.token) : null;
45
- const _remote = currentGitRemote();
46
- const bitbucket = (config.bitbucket && remoteMatchesBitbucketInstance(_remote, config.bitbucket.url)) ? new BitbucketClient(config.bitbucket.url, config.bitbucket.token) : null;
47
- if (config.bitbucket && !bitbucket) {
48
- console.error(`[atlassian-mcp] Bitbucket configured but remote "${_remote || '(none)'}" does not match ${config.bitbucket.url} — Bitbucket tools disabled for this repo.`);
49
- }
50
- async function buildInstructions() {
51
- const branch = currentGitBranch();
52
- const isGitRepo = branch !== '';
53
- const jiraKeys = isGitRepo ? [...new Set(branch.match(/\b[A-Z][A-Z0-9]+-\d+\b/g) ?? [])] : [];
54
- const parsed = bitbucket && _remote ? parseBitbucketRemote(_remote) : null;
55
- const committers = isGitRepo ? getTopCommitters(process.cwd(), 50, 5) : [];
56
- const [jiraMe, bbMe] = await Promise.all([
57
- jira ? jira.whoami().catch(() => null) : Promise.resolve(null),
58
- bitbucket ? bitbucket.whoami().catch(() => null) : Promise.resolve(null),
59
- ]);
60
- const lines = [];
61
- lines.push('# atlassian-mcp');
62
- lines.push('');
63
- lines.push('Self-hosted Jira + Bitbucket Server tooling. Prefer these tools over shelling out to `git log`, `gh`, or any `bitbucket`/`bb` CLI for anything that touches tickets, PRs, reviewers, comments, or user lookups.');
64
- lines.push('');
65
- lines.push('## Configured services');
66
- lines.push(`- Jira: ${jira ? config.jira.url : '(not configured)'}${jiraMe ? ` — you are ${jiraMe.name ?? jiraMe.key ?? '?'}${jiraMe.displayName ? ` "${jiraMe.displayName}"` : ''}` : ''}`);
67
- lines.push(`- Bitbucket: ${bitbucket ? config.bitbucket.url : (config.bitbucket ? `${config.bitbucket.url} — DISABLED for this cwd (remote does not match)` : '(not configured)')}${bbMe ? ` — you are ${bbMe}` : ''}`);
68
- lines.push('');
69
- if (isGitRepo) {
70
- lines.push('## Current repo');
71
- lines.push(`- Branch: ${branch} (may have changed since startup — re-run \`get_dev_context\` to refresh)`);
72
- lines.push(`- Remote: ${_remote || '(none)'}`);
73
- if (parsed)
74
- lines.push(`- Bitbucket repo: ${parsed.projectKey}/${parsed.repoSlug}`);
75
- if (jiraKeys.length)
76
- lines.push(`- Jira keys in branch: ${jiraKeys.join(', ')}`);
77
- }
78
- else {
79
- lines.push('## Current repo');
80
- lines.push('- Not a git repository.');
81
- }
82
- if (committers.length) {
83
- lines.push('');
84
- lines.push('## Recent committers in this repo (last 50 commits)');
85
- for (const c of committers) {
86
- const ident = c.email ? `${c.name} <${c.email}>` : c.name;
87
- lines.push(`- ${c.commits}× ${ident}`);
88
- }
89
- }
90
- lines.push('');
91
- lines.push('## Use these tools — do NOT shell out');
92
- lines.push('- "What am I working on / what\'s the status / show me the context" → call `get_dev_context` first. It returns branch state, linked Jira tickets, the open PR, and reviewer status in one shot.');
93
- lines.push('- Looking up a person\'s username (for reviewers, assignees, mentions) → ALWAYS use `bitbucket_search resource=users` or `jira_search resource=users`. NEVER use `git log`/`git shortlog`/`gh api`/`bb`/any bitbucket CLI to discover who someone is — those return commit-author strings, not Bitbucket/Jira usernames, and the wrong identifier breaks reviewer assignment.');
94
- lines.push('- Reading a Jira ticket → `jira_get` (single) or `jira_search` (many). Mutating → `jira_mutate`.');
95
- lines.push('- Reading a PR → `bitbucket_get_pr`. Creating/updating/merging → `bitbucket_mutate`. Commenting → `bitbucket_comment`.');
96
- lines.push('- Starting work on a ticket (branch + status transition + README) → `start_work`. Closing it (merge + transition) → `complete_work`.');
97
- return lines.join('\n');
98
- }
99
- const _instructions = await buildInstructions();
100
- const server = new Server({ name: 'atlassian-mcp', version: _pkg.version }, { capabilities: { tools: {} }, instructions: _instructions });
101
- server.onerror = (error) => console.error('[MCP Error]', error);
102
- function normalizeBitbucketArgs(args) {
103
- const src = (args && typeof args === 'object') ? args : {};
104
- const out = { ...src };
105
- if (typeof out.project === 'string' && typeof out.projectKey !== 'string')
106
- out.projectKey = out.project;
107
- if (typeof out.repo === 'string' && typeof out.repoSlug !== 'string')
108
- out.repoSlug = out.repo;
109
- return out;
110
- }
111
- function normalizeJiraProjectArgs(args) {
112
- const src = (args && typeof args === 'object') ? args : {};
113
- const out = { ...src };
114
- if (typeof out.project === 'string' && typeof out.projectKey !== 'string')
115
- out.projectKey = out.project;
116
- return out;
117
- }
118
- function normalizeJiraMutateArgs(args) {
119
- const out = normalizeJiraProjectArgs(args);
120
- if (out.create && typeof out.create === 'object') {
121
- const create = { ...out.create };
122
- if (typeof create.project === 'string' && typeof create.projectKey !== 'string')
123
- create.projectKey = create.project;
124
- out.create = create;
125
- }
126
- return out;
127
- }
128
- function validateAttachmentArgs(a) {
129
- if (a.frames !== undefined && (typeof a.frames !== 'number' || !Number.isFinite(a.frames) || a.frames < 1 || a.frames > 60)) {
130
- throw new McpError(ErrorCode.InvalidParams, 'frames must be a number between 1 and 60.');
131
- }
132
- if (a.start !== undefined && (typeof a.start !== 'number' || !Number.isFinite(a.start) || a.start < 0)) {
133
- throw new McpError(ErrorCode.InvalidParams, 'start must be a non-negative number of seconds.');
134
- }
135
- if (a.end !== undefined && (typeof a.end !== 'number' || !Number.isFinite(a.end) || a.end <= 0)) {
136
- throw new McpError(ErrorCode.InvalidParams, 'end must be a positive number of seconds.');
137
- }
138
- if (typeof a.start === 'number' && typeof a.end === 'number' && a.end <= a.start) {
139
- throw new McpError(ErrorCode.InvalidParams, 'end must be greater than start.');
140
- }
141
- if (a.mode !== undefined && a.mode !== 'uniform' && a.mode !== 'scenes') {
142
- throw new McpError(ErrorCode.InvalidParams, 'mode must be "uniform" or "scenes".');
143
- }
144
- if (a.maxDimension !== undefined && (typeof a.maxDimension !== 'number' || !Number.isFinite(a.maxDimension) || a.maxDimension < 64 || a.maxDimension > 4096)) {
145
- throw new McpError(ErrorCode.InvalidParams, 'maxDimension must be a number between 64 and 4096.');
146
- }
147
- if (a.quality !== undefined && (typeof a.quality !== 'number' || !Number.isFinite(a.quality) || a.quality < 1 || a.quality > 100)) {
148
- throw new McpError(ErrorCode.InvalidParams, 'quality must be a number between 1 and 100.');
149
- }
150
- if (a.sceneThreshold !== undefined && (typeof a.sceneThreshold !== 'number' || !Number.isFinite(a.sceneThreshold) || a.sceneThreshold <= 0 || a.sceneThreshold > 1)) {
151
- throw new McpError(ErrorCode.InvalidParams, 'sceneThreshold must be a number in (0, 1].');
152
- }
153
- }
154
- const JIRA_WIKI_MARKUP_HINT = 'Use Jira wiki markup (Atlassian renderer syntax), not GitHub/CommonMark markdown.';
155
- function issueTypePrefix(issueType) {
156
- const t = issueType.toLowerCase();
157
- if (t === 'bug' || t === 'bugfix' || t === 'defect')
158
- return 'bugfix';
159
- if (t === 'hotfix')
160
- return 'hotfix';
161
- if (t === 'task' || t === 'sub-task' || t === 'subtask')
162
- return 'task';
163
- return 'feature'; // story, feature, epic, improvement, etc.
164
- }
165
- function slugifyBranchName(issueKey, summary, issueType) {
166
- const prefix = issueTypePrefix(issueType);
167
- const slug = summary
168
- .toLowerCase()
169
- .replace(/[^a-z0-9]+/g, '-')
170
- .replace(/^-|-$/g, '')
171
- .slice(0, 40)
172
- .replace(/-$/, '');
173
- return `${prefix}/${issueKey}-${slug}`;
174
- }
175
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
176
- tools: [
177
- // ── Git (always available) ────────────────────────────────────────────
178
- {
179
- name: 'git_get_context',
180
- description: 'Start here for any coding or review task: current branch, upstream ahead/behind, remote URL, recent commits, working tree status, diff stat summary, and Jira keys detected in the branch name. Pass includeDiff=true to also include the full uncommitted diff.',
181
- inputSchema: {
182
- type: 'object',
183
- properties: {
184
- repoPath: { type: 'string', description: 'Path to the git repository (defaults to cwd)' },
185
- commitLimit: { type: 'number', description: 'Number of recent commits to show (default 10)', default: 10 },
186
- includeDiff: { type: 'boolean', description: 'Include full uncommitted diff (default false)', default: false },
187
- },
188
- },
189
- },
190
- {
191
- name: 'git_get_diff',
192
- description: 'Get a diff between two git refs or commits. Use when you need to compare a feature branch to main, inspect a specific commit range, or review changes between two refs. For large diffs, increase maxChars or use charOffset to page through them.',
193
- inputSchema: {
194
- type: 'object',
195
- properties: {
196
- repoPath: { type: 'string', description: 'Path to the git repository (defaults to cwd)' },
197
- fromRef: { type: 'string', description: 'Base ref or commit' },
198
- toRef: { type: 'string', description: 'Target ref or commit (requires fromRef)' },
199
- maxChars: { type: 'number', description: 'Max characters to return (default 8000). Increase for large diffs.', default: 8000 },
200
- charOffset: { type: 'number', description: 'Skip this many characters from the start (for paging large diffs)', default: 0 },
201
- },
202
- },
203
- },
204
- // ── Combined context (jira + bitbucket, or either alone) ─────────────
205
- ...(jira || bitbucket ? [{
206
- name: 'get_dev_context',
207
- description: 'Master entry point for "what am I working on / what\'s the status", and before any review or coding task. Returns: git branch + upstream state, Jira ticket overview (status, transitions, sprint, comments), open PR with reviewer approvals, and actionable next-step hints (create PR, merge, address blockers).',
208
- inputSchema: {
209
- type: 'object',
210
- properties: {
211
- repoPath: { type: 'string', description: 'Local path to the git repo (defaults to cwd)' },
212
- },
213
- },
214
- }] : []),
215
- // ── Jira ──────────────────────────────────────────────────────────────
216
- ...(jira ? [
217
- {
218
- name: 'start_work',
219
- description: 'Start working on a Jira ticket end-to-end: resolves the ticket (by key or free-text search with a picker when multiple match), creates a local branch with an auto-generated name, fetches the project README from Bitbucket so you have commit/PR conventions in context, and prints a next-steps summary. If issueKey is omitted, provide query for free-text search.',
220
- inputSchema: {
221
- type: 'object',
222
- properties: {
223
- issueKey: { type: 'string', description: 'Jira issue key, e.g. FOO-123 (provide this OR query)' },
224
- query: { type: 'string', description: 'Free-text search when issueKey is unknown — shows a picker if multiple tickets match' },
225
- repoPath: { type: 'string', description: 'Local repo path (defaults to cwd)' },
226
- baseBranch: { type: 'string', description: 'Branch to base off (default: master)' },
227
- branchName: { type: 'string', description: 'Override the generated branch name' },
228
- transitionName: { type: 'string', description: 'Jira transition to apply, e.g. "In Progress" (optional)' },
229
- push: { type: 'boolean', description: 'Push branch to remote after creation (default false)', default: false },
230
- },
231
- },
232
- },
233
- {
234
- name: 'jira_search',
235
- description: 'Discover Jira resources (tickets, projects, boards, sprints, versions, users). Set resource:\n• "issues" (default) — search by text, JQL, project, status, assignee, issue type, or mine=true for your queue\n• "projects" — list all projects and their keys\n• "issue_types" — valid types and statuses for a project\n• "boards" — list boards (pass project to filter by project key); use this to find the boardId before fetching sprints or board_overview\n• "sprints" — sprints for a board (pass boardId); if you don\'t know the boardId, first use resource=boards\n• "board_overview" — active/future sprints with their issues for a board (pass boardId); use when asked "what\'s in the sprint", "show me the board", or "what\'s everyone working on"\n• "versions" — list fix versions/releases for a project (pass project; optionally pass query to filter by name substring). If the version you need does not exist, create it yourself with `jira_version action=create` — do NOT ask the user to make it in the Jira UI.\n• "users" — find users by name/email (pass query)',
236
- inputSchema: {
237
- type: 'object',
238
- properties: {
239
- resource: { type: 'string', enum: ['issues', 'projects', 'issue_types', 'boards', 'sprints', 'board_overview', 'versions', 'users'], description: 'What to search (default: issues)' },
240
- mine: { type: 'boolean', description: 'Return issues assigned to you (resource=issues only)' },
241
- query: { type: 'string', description: 'Text search or user name query' },
242
- jql: { type: 'string', description: 'Raw JQL (resource=issues only, overrides other filters)' },
243
- project: { type: 'string', description: 'Project key filter or scope for issue_types/boards' },
244
- status: { type: 'string', description: 'Status filter (issues only, or board_overview to filter issues by status)' },
245
- assignee: { type: 'string', description: 'Assignee username filter (issues only, or board_overview to filter issues by assignee)' },
246
- issueType: { type: 'string', description: 'Issue type filter (issues only)' },
247
- boardId: { type: 'number', description: 'Board ID (required for resource=sprints or board_overview)' },
248
- sprintState: { type: 'string', description: 'Sprint state filter: active, future, closed (sprints and board_overview)' },
249
- includeIssues: { type: 'boolean', description: 'Include issues per sprint in board_overview (default true)', default: true },
250
- maxResults: { type: 'number', description: 'Max results (default 20)', default: 20 },
251
- startAt: { type: 'number', description: 'Pagination offset (default 0)', default: 0 },
252
- },
253
- },
254
- },
255
- {
256
- name: 'jira_get',
257
- description: 'Full details for one Jira issue: summary, description, status, assignee, sprint, available transitions, recent comments, and a list of attachments (filename, size, mime type, attachment ID). To view an attachment\'s contents (e.g. an image), call jira_get_attachment with the attachment ID surfaced here.',
258
- inputSchema: {
259
- type: 'object',
260
- properties: {
261
- issueKey: { type: 'string', description: 'Jira issue key, e.g. FOO-123' },
262
- includeComments: { type: 'boolean', description: 'Include comments (default true)', default: true },
263
- commentsMaxResults: { type: 'number', description: 'Max comments (default 10)', default: 10 },
264
- commentsStartAt: { type: 'number', description: 'Comment pagination offset (default 0)', default: 0 },
265
- includeTransitions: { type: 'boolean', description: 'Include available transitions (default true)', default: true },
266
- includeSprint: { type: 'boolean', description: 'Include sprint data (default true)', default: true },
267
- fullDescription: { type: 'boolean', description: 'Return the full description even when long (default false — descriptions over ~2000 chars are truncated to save context)', default: false },
268
- },
269
- required: ['issueKey'],
270
- },
271
- },
272
- {
273
- name: 'jira_mutate',
274
- description: `Create/update a ticket, transition status, assign, comment, link issues, or log work — bundles create/update/transition/comment/link/worklog in one call. ${JIRA_WIKI_MARKUP_HINT}`,
275
- inputSchema: {
276
- type: 'object',
277
- properties: {
278
- issueKey: { type: 'string', description: 'Existing issue key to mutate (optional if create is provided)' },
279
- create: {
280
- type: 'object',
281
- properties: {
282
- projectKey: { type: 'string', description: 'Jira project code (optional, auto-resolved when omitted)' },
283
- project: { type: 'string', description: 'Alias for projectKey' },
284
- issueType: { type: 'string', description: 'Issue type name, e.g. Bug, Story, Task, Sub-task' },
285
- summary: { type: 'string', description: 'Issue title' },
286
- description: { type: 'string', description: `Issue description (optional). ${JIRA_WIKI_MARKUP_HINT}` },
287
- assignee: { type: 'string', description: 'Username to assign to (optional)' },
288
- priority: { type: 'string', description: 'Priority name (optional)' },
289
- labels: { type: 'array', items: { type: 'string' }, description: 'Labels to apply (optional)' },
290
- fixVersion: { type: 'string', description: 'Fix version name (optional)' },
291
- parent: { type: 'string', description: 'Parent issue key. For Sub-task issue types this sets the Jira parent. If the key points to an Epic, the Epic Link custom field is set automatically instead (Jira Server epic membership).' },
292
- epicLink: { type: 'string', description: 'Epic issue key to attach the new issue to as an epic child (Jira Server Epic Link). Overrides parent if both are passed.' },
293
- },
294
- required: ['issueType', 'summary'],
295
- },
296
- update: {
297
- type: 'object',
298
- properties: {
299
- summary: { type: 'string', description: 'New summary (optional)' },
300
- description: { type: 'string', description: `New description (optional). ${JIRA_WIKI_MARKUP_HINT}` },
301
- assignee: { type: 'string', description: 'New assignee username, or empty string to unassign (optional)' },
302
- priority: { type: 'string', description: 'New priority name (optional)' },
303
- labels: { type: 'array', items: { type: 'string' }, description: 'Replace label set (pass [] to clear)' },
304
- fixVersion: { type: 'string', description: 'Fix version name, or empty string to clear (optional)' },
305
- epicLink: { type: 'string', description: 'Epic issue key to set as parent epic (Jira Server Epic Link), or empty string to clear.' },
306
- },
307
- },
308
- sprintId: { type: 'number', description: 'Sprint ID to add the issue into (optional)' },
309
- removeFromSprint: { type: 'boolean', description: 'Move the issue to the backlog (remove from any sprint)' },
310
- transitionId: { type: 'string', description: 'Transition ID (optional if transitionName provided)' },
311
- transitionName: { type: 'string', description: 'Transition name, e.g. "In Progress" (optional if transitionId provided)' },
312
- comment: { type: 'string', description: `Comment to add after other mutations (optional). ${JIRA_WIKI_MARKUP_HINT}` },
313
- link: {
314
- type: 'object',
315
- description: 'Create an issue link, e.g. "FOO-123 blocks BAR-456"',
316
- properties: {
317
- linkType: { type: 'string', description: 'Link type name, e.g. "Blocks", "Relates to", "Duplicates"' },
318
- targetIssueKey: { type: 'string', description: 'The other issue in the relationship' },
319
- direction: { type: 'string', enum: ['outward', 'inward'], description: 'outward (default): issueKey → target; inward: target → issueKey' },
320
- },
321
- required: ['linkType', 'targetIssueKey'],
322
- },
323
- worklog: {
324
- type: 'object',
325
- description: 'Log time spent on this issue',
326
- properties: {
327
- timeSpent: { type: 'string', description: 'Time in Jira format, e.g. "2h 30m" or "1d"' },
328
- comment: { type: 'string', description: 'Work description (optional)' },
329
- started: { type: 'string', description: 'ISO 8601 datetime when work started (defaults to now)' },
330
- },
331
- required: ['timeSpent'],
332
- },
333
- },
334
- },
335
- },
336
- {
337
- name: 'jira_get_attachment',
338
- description: 'Fetch a Jira attachment by ID and return its contents inline. Images are auto-resized + re-encoded; text/JSON/XML return as text; videos and animated images (GIF/APNG/animated WebP) are decoded with ffmpeg into sampled frames (re-call with start/end/frames or mode=scenes to refine); audio returns as an audio block; PDFs return extracted text. Oversized/non-renderable files are saved to a temp file and the path returned. Use jira_get first to discover attachment IDs.',
339
- inputSchema: {
340
- type: 'object',
341
- properties: {
342
- attachmentId: { type: 'string', description: 'Numeric attachment ID from jira_get output' },
343
- saveTo: { type: 'string', description: 'Optional absolute path to save the original (un-resized) file to disk instead of returning inline' },
344
- maxDimension: { type: 'number', description: 'Max long-edge size in pixels for inline images (default 1568 for images, 768 for video frames).' },
345
- quality: { type: 'number', description: 'JPEG quality for re-encoded inline images (1-100, default 85 for images, 65 for video frames). Ignored for images with alpha (encoded as PNG).' },
346
- frames: { type: 'number', description: 'Video/animated-image only: number of frames to sample (default 6, range 1-60). Higher = more detail + more context.' },
347
- start: { type: 'number', description: 'Video/animated-image only: start of sample window in seconds (default 0). Use with end/frames to zoom into a moment of interest after a coarse first pass.' },
348
- end: { type: 'number', description: 'Video/animated-image only: end of sample window in seconds (default full duration). Must be greater than start.' },
349
- mode: { type: 'string', enum: ['uniform', 'scenes'], description: 'Video/animated-image only: "uniform" samples N frames evenly (default); "scenes" uses ffmpeg scene-change detection, better for screencasts/narrative content.' },
350
- sceneThreshold: { type: 'number', description: 'Video/animated-image only: scene-change sensitivity in 0-1 (default 0.3). Only used when mode=scenes. Lower = more frames, higher = fewer.' },
351
- },
352
- required: ['attachmentId'],
353
- },
354
- },
355
- {
356
- name: 'jira_comment',
357
- description: `Add, update, or delete a comment on a Jira issue. action defaults to "add". Can only edit/delete your own comments. ${JIRA_WIKI_MARKUP_HINT}`,
358
- inputSchema: {
359
- type: 'object',
360
- properties: {
361
- action: { type: 'string', enum: ['add', 'update', 'delete'], description: 'Operation (default: add)' },
362
- issueKey: { type: 'string', description: 'Jira issue key, e.g. FOO-123' },
363
- commentId: { type: 'string', description: 'Comment ID (required for update/delete)' },
364
- body: { type: 'string', description: `Comment text. ${JIRA_WIKI_MARKUP_HINT} Required for add/update.` },
365
- },
366
- required: ['issueKey'],
367
- },
368
- },
369
- {
370
- name: 'jira_version',
371
- description: 'Manage Jira fix versions (releases): create, update, release, archive, delete. action defaults to "create". For create pass projectKey + name. For update/release/archive/delete pass id (look it up via jira_search resource=versions). "release" sets released=true and defaults releaseDate to today. Once a version exists you can set it on tickets via jira_mutate update.fixVersion.',
372
- inputSchema: {
373
- type: 'object',
374
- properties: {
375
- action: { type: 'string', enum: ['create', 'update', 'release', 'archive', 'delete'], description: 'Operation (default: create)' },
376
- projectKey: { type: 'string', description: 'Jira project code (required for create when not auto-resolvable)' },
377
- project: { type: 'string', description: 'Alias for projectKey' },
378
- id: { type: 'string', description: 'Version id (required for update/release/archive/delete; look up via jira_search resource=versions)' },
379
- name: { type: 'string', description: 'Version name, e.g. "9.1.0" (required for create; optional rename for update)' },
380
- description: { type: 'string', description: 'Version description (optional)' },
381
- startDate: { type: 'string', description: 'Start date in YYYY-MM-DD (optional)' },
382
- releaseDate: { type: 'string', description: 'Release date in YYYY-MM-DD (optional; defaults to today on action=release)' },
383
- released: { type: 'boolean', description: 'Released flag (optional; action=release forces true)' },
384
- archived: { type: 'boolean', description: 'Archived flag (optional; action=archive forces true)' },
385
- },
386
- },
387
- }
388
- ] : []),
389
- ...(bitbucket ? [{
390
- name: 'bitbucket_search',
391
- description: 'Discover Bitbucket resources (PRs, repos, branches, users). Set resource:\n• "pull_requests" (default) — list PRs by state/branch/text; mine=true for your inbox\n• "repos" — list repositories in a project\n• "branches" — list or filter branches in a repo\n• "users" — find users by name/email (pass query); add projectKey+repoSlug to restrict to users with repo access. ALWAYS use this to look up valid usernames before adding reviewers to a PR.',
392
- inputSchema: {
393
- type: 'object',
394
- properties: {
395
- resource: { type: 'string', enum: ['pull_requests', 'repos', 'branches', 'users'], description: 'What to search (default: pull_requests)' },
396
- mine: { type: 'boolean', description: 'Return your own PRs by role (resource=pull_requests only)' },
397
- role: { type: 'string', enum: ['author', 'reviewer', 'participant'], description: 'Your role filter when mine=true' },
398
- projectKey: { type: 'string', description: 'Bitbucket project code, e.g. "ENG"' },
399
- project: { type: 'string', description: 'Alias for projectKey' },
400
- repoSlug: { type: 'string', description: 'Repository slug' },
401
- repo: { type: 'string', description: 'Alias for repoSlug' },
402
- query: { type: 'string', description: 'Name or email filter (resource=users only)' },
403
- state: { type: 'string', enum: ['OPEN', 'MERGED', 'DECLINED'], description: 'PR state filter (default OPEN)' },
404
- fromBranch: { type: 'string', description: 'Filter PRs from this source branch' },
405
- text: { type: 'string', description: 'Filter PRs by title/description text' },
406
- filter: { type: 'string', description: 'Branch name filter (resource=branches only)' },
407
- limit: { type: 'number', description: 'Max results per page (default 25)', default: 25 },
408
- start: { type: 'number', description: 'Pagination offset (default 0)', default: 0 },
409
- },
410
- },
411
- },
412
- {
413
- name: 'bitbucket_get_pr',
414
- description: 'Full details for one PR: metadata, commits, open comments, blockers, optional diff, and any attachments referenced from the description or comments (with attachment ID + filename). IMPORTANT: The PR branch is often not the locally checked-out branch. Do NOT read files with local tools (Read, git_get_diff, etc.) for PR context — use bitbucket_get_file with the PR\'s source branch instead. To view an attachment\'s contents, call bitbucket_get_attachment with the surfaced attachment ID. The response includes a "Viewing as" line — if it says "you are the author", do NOT add review comments or a summary unless explicitly asked; just answer questions about the PR. If it says "you are a reviewer", default to posting inline comments for suggested changes and a final summary comment.',
415
- inputSchema: {
416
- type: 'object',
417
- properties: {
418
- projectKey: { type: 'string', description: 'Bitbucket project code (usually auto-detected)' },
419
- project: { type: 'string', description: 'Alias for projectKey' },
420
- repoSlug: { type: 'string', description: 'Repository slug (usually auto-detected)' },
421
- repo: { type: 'string', description: 'Alias for repoSlug' },
422
- prId: { type: 'number', description: 'Pull request number (optional if fromBranch provided or running from a checked-out branch)' },
423
- fromBranch: { type: 'string', description: 'Source branch — auto-resolves the open PR; omit to use current checked-out branch' },
424
- includeCommits: { type: 'boolean', description: 'Include commit list (default true)', default: true },
425
- includeComments: { type: 'boolean', description: 'Include review comments and blockers (default true)', default: true },
426
- includeDiff: { type: 'boolean', description: 'Include diff text (default false)', default: false },
427
- includeBuildStatus: { type: 'boolean', description: 'Include CI/build status for the head commit (default true)', default: true },
428
- commentsState: { type: 'string', enum: ['ALL', 'OPEN', 'RESOLVED', 'PENDING'], description: 'Comment state filter (default ALL — returns every comment with its state badge so nothing is silently hidden). Pass OPEN/RESOLVED only when explicitly narrowing.', default: 'ALL' },
429
- commentsSeverity: { type: 'string', enum: ['ALL', 'NORMAL', 'BLOCKER'], description: 'Comment severity filter (default ALL)', default: 'ALL' },
430
- commentsLimit: { type: 'number', description: 'Max comments (default 50)', default: 50 },
431
- commentsStart: { type: 'number', description: 'Comment pagination offset (default 0)', default: 0 },
432
- commitsLimit: { type: 'number', description: 'Max commits (default 25)', default: 25 },
433
- diffMaxChars: { type: 'number', description: 'Max diff chars when includeDiff=true (default 8000)', default: 8000 },
434
- fullDescription: { type: 'boolean', description: 'Return the full PR description even when long (default false — descriptions over ~2000 chars are truncated to save context)', default: false },
435
- },
436
- },
437
- },
438
- {
439
- name: 'bitbucket_mutate',
440
- description: 'Create, update, approve/unapprove, mark needs_work, decline, or merge a PR — in one call. Auto-targets the open PR for the current branch when prId is omitted. needs_work sets your reviewer status to "Needs work" (Bitbucket Server\'s changes-requested signal); revert with action=unapprove.',
441
- inputSchema: {
442
- type: 'object',
443
- properties: {
444
- projectKey: { type: 'string', description: 'Bitbucket project code (usually auto-detected)' },
445
- project: { type: 'string', description: 'Alias for projectKey' },
446
- repoSlug: { type: 'string', description: 'Repository slug (usually auto-detected)' },
447
- repo: { type: 'string', description: 'Alias for repoSlug' },
448
- prId: { type: 'number', description: 'Target PR number (optional, auto-resolved from branch)' },
449
- action: { type: 'string', enum: ['approve', 'unapprove', 'needs_work', 'decline', 'merge'], description: 'Lifecycle action to perform (optional). needs_work = mark your reviewer status as "Needs work" (changes requested).' },
450
- mergeStrategy: { type: 'string', enum: ['MERGE_COMMIT', 'SQUASH', 'FAST_FORWARD'], description: 'Merge strategy (action=merge only)' },
451
- mergeMessage: { type: 'string', description: 'Custom merge commit message (action=merge only)' },
452
- declineMessage: { type: 'string', description: 'Decline message (action=decline only)' },
453
- create: {
454
- type: 'object',
455
- properties: {
456
- title: { type: 'string', description: 'PR title' },
457
- description: { type: 'string', description: 'PR description (optional)' },
458
- fromBranch: { type: 'string', description: 'Source branch (defaults to current branch)' },
459
- toBranch: { type: 'string', description: 'Target branch (default: master)' },
460
- reviewers: { type: 'array', items: { type: 'string' }, description: 'Reviewer usernames. Use bitbucket_search resource=users to look up valid usernames before setting this.' },
461
- pickReviewers: { type: 'boolean', description: 'Show an interactive reviewer picker before creating the PR (lists users with repo access)' },
462
- },
463
- required: ['title'],
464
- },
465
- update: {
466
- type: 'object',
467
- properties: {
468
- title: { type: 'string', description: 'Updated PR title (optional)' },
469
- description: { type: 'string', description: 'Updated description, or empty string to clear (optional)' },
470
- toBranch: { type: 'string', description: 'Updated target branch (optional)' },
471
- reviewers: { type: 'array', items: { type: 'string' }, description: 'Updated reviewer usernames. Empty array clears reviewers. Use bitbucket_search resource=users to look up valid usernames before setting this.' },
472
- },
473
- },
474
- },
475
- },
476
- },
477
- {
478
- name: 'bitbucket_comment',
479
- description: `Add, update, or delete a PR comment. action defaults to "add". This tool posts COMMENTS, never tasks — never set severity=BLOCKER to create a task. severity=BLOCKER is rejected on add and only ever valid on update to escalate an already-blocking comment. To create a task, use bitbucket_pr_tasks (action=create), and only when the user explicitly asks for a task. For code changes, ALWAYS use inline comments with suggestion when exact replacement code is available. Keep any explanatory text before the suggestion block only (never after), or Bitbucket may hide Apply suggestion. Replies MUST use commentId. Only one reply per thread per author: if you already replied to a comment, update your existing reply (action=update commentId=<your-reply-id>) instead of posting a second one. The server enforces this and will reject the second add. Keep comments concise, no emojis. Only call proactively (without being asked) when you are a reviewer on the PR (i.e. "Viewing as" says "you are a reviewer") — never post unsolicited comments on PRs you authored. For inline comments: ALWAYS pass fromHash + toHash matching the commit you actually reviewed (read from bitbucket_pr_diff or bitbucket_get_pr output). Without them the anchor falls back to current PR head, and if the branch advanced between review and post the line number will point at unrelated code. When the comment body references another comment by id, render it as a markdown hyperlink \`[#<id>](<baseUrl>/projects/<PROJECT>/repos/<REPO>/pull-requests/<prId>/overview?commentId=<id>)\` — never a bare \`#<id>\`, which is unclickable in Bitbucket's renderer.`,
480
- inputSchema: {
481
- type: 'object',
482
- properties: {
483
- action: { type: 'string', enum: ['add', 'update', 'delete'], description: 'Operation (default: add)' },
484
- projectKey: { type: 'string', description: 'Bitbucket project code (usually auto-detected)' },
485
- project: { type: 'string', description: 'Alias for projectKey' },
486
- repoSlug: { type: 'string', description: 'Repository slug (usually auto-detected)' },
487
- repo: { type: 'string', description: 'Alias for repoSlug' },
488
- prId: { type: 'number', description: 'Pull request number' },
489
- commentId: { type: 'number', description: 'Comment ID to reply to, update, or delete' },
490
- text: { type: 'string', description: 'Comment text for add/update. No filler, no emojis. If suggestion is used, keep this optional and brief; it is placed before the suggestion block.' },
491
- filePath: { type: 'string', description: 'File path for inline comment (must pair with line)' },
492
- srcPath: { type: 'string', description: 'Source path if file was renamed (optional, defaults to filePath)' },
493
- line: { type: 'number', description: 'Line number to anchor inline comment (must pair with filePath)' },
494
- lineType: { type: 'string', enum: ['ADDED', 'REMOVED', 'CONTEXT'], description: 'Diff line type (default ADDED)' },
495
- fileType: { type: 'string', enum: ['TO', 'FROM'], description: 'Diff side: TO (new, default) or FROM (old)' },
496
- multilineStartLine: { type: 'number', description: 'First line of multiline anchor (pair with line as last line)' },
497
- multilineStartLineType: { type: 'string', enum: ['ADDED', 'REMOVED', 'CONTEXT'], description: 'Line type for multilineStartLine' },
498
- fromHash: { type: 'string', description: 'Base/target commit of the diff you reviewed (from bitbucket_pr_diff or bitbucket_get_pr). Pair with toHash so the anchor binds to the exact commit, not whatever the PR head happens to be at post time.' },
499
- toHash: { type: 'string', description: 'Source/feature commit of the diff you reviewed (from bitbucket_pr_diff or bitbucket_get_pr). Pair with fromHash. If the PR has advanced since, Bitbucket will mark the comment outdated — that is the correct behaviour.' },
500
- suggestion: { type: 'string', description: 'Replacement code to suggest. Use whenever proposing a concrete code change. Posted as the final ```suggestion``` block so Apply suggestion appears. Requires filePath + line.' },
501
- state: { type: 'string', enum: ['OPEN', 'RESOLVED'], description: 'Task state for BLOCKER comments (update only)' },
502
- threadResolved: { type: 'boolean', description: 'Resolve/reopen normal comment thread (update only)' },
503
- severity: { type: 'string', enum: ['NORMAL', 'BLOCKER'], description: 'Comment severity. Leave unset — comments default to NORMAL. BLOCKER turns the comment into a task and is REJECTED on add; only pass BLOCKER on update to escalate an existing comment when the user explicitly asks. To create a task, use bitbucket_pr_tasks instead.' },
504
- },
505
- required: ['prId'],
506
- },
507
- },
508
- {
509
- name: 'bitbucket_get_file',
510
- description: 'Raw file content from Bitbucket at a branch, tag, or commit. CRITICAL: if the PR branch being reviewed is NOT the currently checked-out local branch, ALL additional file context for that review MUST come from this tool — never from local Read, git_get_diff, or any tool that reads local disk. Pass the PR source branch as ref.',
511
- inputSchema: {
512
- type: 'object',
513
- properties: {
514
- projectKey: { type: 'string', description: 'Bitbucket project code (usually auto-detected)' },
515
- project: { type: 'string', description: 'Alias for projectKey' },
516
- repoSlug: { type: 'string', description: 'Repository slug (usually auto-detected)' },
517
- repo: { type: 'string', description: 'Alias for repoSlug' },
518
- path: { type: 'string', description: 'File path, e.g. "src/index.ts"' },
519
- ref: { type: 'string', description: 'Branch, tag, or commit hash (defaults to default branch)' },
520
- },
521
- required: ['path'],
522
- },
523
- },
524
- {
525
- name: 'bitbucket_get_attachment',
526
- description: 'Fetch a Bitbucket repo attachment by ID and return its contents inline. Attachments are repo-scoped, referenced from PR descriptions/comments via attachment:<id> markdown; use bitbucket_get_pr first to surface IDs. Images are auto-resized + re-encoded; text/JSON/XML return as text; videos and animated images (GIF/APNG/animated WebP) are decoded with ffmpeg into sampled frames (re-call with start/end/frames or mode=scenes to refine); audio returns as an audio block; PDFs return extracted text. Oversized/non-renderable files are saved to a temp file and the path returned.',
527
- inputSchema: {
528
- type: 'object',
529
- properties: {
530
- projectKey: { type: 'string', description: 'Bitbucket project code (usually auto-detected)' },
531
- project: { type: 'string', description: 'Alias for projectKey' },
532
- repoSlug: { type: 'string', description: 'Repository slug (usually auto-detected)' },
533
- repo: { type: 'string', description: 'Alias for repoSlug' },
534
- attachmentId: { type: 'string', description: 'Numeric attachment ID' },
535
- saveTo: { type: 'string', description: 'Optional absolute path to save the original (un-resized) file to disk instead of returning inline' },
536
- maxDimension: { type: 'number', description: 'Max long-edge size in pixels for inline images (default 1568 for images, 768 for video frames).' },
537
- quality: { type: 'number', description: 'JPEG quality for re-encoded inline images (1-100, default 85 for images, 65 for video frames). Ignored for images with alpha (encoded as PNG).' },
538
- frames: { type: 'number', description: 'Video/animated-image only: number of frames to sample (default 6, range 1-60).' },
539
- start: { type: 'number', description: 'Video/animated-image only: start of sample window in seconds (default 0). Use with end/frames to zoom in.' },
540
- end: { type: 'number', description: 'Video/animated-image only: end of sample window in seconds (default full duration). Must be greater than start.' },
541
- mode: { type: 'string', enum: ['uniform', 'scenes'], description: 'Video/animated-image only: "uniform" samples N frames evenly (default); "scenes" uses scene-change detection.' },
542
- sceneThreshold: { type: 'number', description: 'Video/animated-image only: scene-change sensitivity in 0-1 (default 0.3). Only used when mode=scenes. Lower = more frames, higher = fewer.' },
543
- },
544
- required: ['attachmentId'],
545
- },
546
- },
547
- {
548
- name: 'bitbucket_pr_tasks',
549
- description: 'Manage PR tasks (checklist items): list, create, resolve, reopen, delete. Tasks are distinct from comments — they appear as a checklist in the PR sidebar.',
550
- inputSchema: {
551
- type: 'object',
552
- properties: {
553
- action: { type: 'string', enum: ['list', 'create', 'resolve', 'reopen', 'delete'], description: 'Operation (default: list)' },
554
- projectKey: { type: 'string', description: 'Bitbucket project code (usually auto-detected)' },
555
- project: { type: 'string', description: 'Alias for projectKey' },
556
- repoSlug: { type: 'string', description: 'Repository slug (usually auto-detected)' },
557
- repo: { type: 'string', description: 'Alias for repoSlug' },
558
- prId: { type: 'number', description: 'Pull request number' },
559
- taskId: { type: 'number', description: 'Task ID (required for resolve/reopen/delete)' },
560
- text: { type: 'string', description: 'Task description (required for create)' },
561
- commentId: { type: 'number', description: 'Anchor the task to a specific comment ID (optional for create)' },
562
- },
563
- required: ['prId'],
564
- },
565
- },
566
- ] : []),
567
- // ── Combined workflow ─────────────────────────────────────────────────
568
- ...(jira && bitbucket ? [{
569
- name: 'complete_work',
570
- description: 'Close the loop on a finished branch: merges the open PR and transitions the Jira ticket to Done (or a named transition). Mirrors start_work.',
571
- inputSchema: {
572
- type: 'object',
573
- properties: {
574
- issueKey: { type: 'string', description: 'Jira issue key to transition (auto-detected from branch name if omitted)' },
575
- prId: { type: 'number', description: 'PR to merge (auto-detected from current branch if omitted)' },
576
- repoPath: { type: 'string', description: 'Local repo path (defaults to cwd)' },
577
- transitionName: { type: 'string', description: 'Jira transition to apply after merge (default: "Done")' },
578
- mergeStrategy: { type: 'string', enum: ['MERGE_COMMIT', 'SQUASH', 'FAST_FORWARD'], description: 'Merge strategy (optional)' },
579
- mergeMessage: { type: 'string', description: 'Custom merge commit message (optional)' },
580
- projectKey: { type: 'string', description: 'Bitbucket project code (usually auto-detected)' },
581
- repoSlug: { type: 'string', description: 'Repository slug (usually auto-detected)' },
582
- skipJiraTransition: { type: 'boolean', description: 'Skip transitioning the Jira ticket (default false)' },
583
- },
584
- },
585
- }] : [])
586
- ],
587
- }));
588
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
589
- const { name, arguments: args = {} } = request.params;
590
- try {
591
- switch (name) {
592
- // Git
593
- case 'git_get_context':
594
- return getContext(args);
595
- case 'git_get_diff': {
596
- const diffArgs = args;
597
- const result = getDiff(diffArgs);
598
- const raw = result.content[0].text;
599
- const offset = diffArgs.charOffset ?? 0;
600
- const limit = diffArgs.maxChars ?? 8000;
601
- if (offset === 0 && raw.length <= limit)
602
- return result;
603
- const chunk = raw.slice(offset, offset + limit);
604
- const remaining = raw.length - offset - chunk.length;
605
- const suffix = remaining > 0 ? `\n\n... (${remaining} more chars, use charOffset=${offset + chunk.length})` : '';
606
- return { content: [{ type: 'text', text: chunk + suffix }] };
607
- }
608
- // Combined context + workflow
609
- case 'get_dev_context':
610
- if (!jira && !bitbucket)
611
- throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
612
- return await getDevContext(args, jira, bitbucket);
613
- case 'start_work': {
614
- if (!jira)
615
- throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
616
- const a = args;
617
- if (!a.issueKey && !a.query) {
618
- throw new Error('Provide issueKey (e.g. FOO-123) or query (free-text search).');
619
- }
620
- // Resolve issueKey from free-text query if not provided directly
621
- let issueKey = a.issueKey;
622
- if (!issueKey && a.query) {
623
- const candidates = await jira.findIssues(a.query, 10);
624
- if (candidates.length === 0) {
625
- return { content: [{ type: 'text', text: `No Jira tickets found for: "${a.query}"` }] };
626
- }
627
- if (candidates.length === 1) {
628
- issueKey = candidates[0].key;
629
- }
630
- else {
631
- // Multiple matches — present a picker
632
- const pickerMessage = [
633
- `Found ${candidates.length} tickets matching "${a.query}". Which one do you want to work on?`,
634
- ...candidates.map((c, i) => `${i + 1}. [${c.key}] ${c.summary} (${c.status})`),
635
- ].join('\n');
636
- try {
637
- const pickerResult = await server.elicitInput({
638
- message: pickerMessage,
639
- requestedSchema: {
640
- type: 'object',
641
- properties: {
642
- ticket: {
643
- type: 'string',
644
- title: 'Select a ticket',
645
- oneOf: [
646
- ...candidates.map((c) => ({ const: c.key, title: `[${c.key}] ${c.summary}` })),
647
- { const: '__cancel__', title: 'Cancel' },
648
- ],
649
- },
650
- },
651
- required: ['ticket'],
652
- },
653
- });
654
- if (pickerResult.action === 'cancel' ||
655
- pickerResult.action === 'decline' ||
656
- pickerResult.content?.ticket === '__cancel__') {
657
- return { content: [{ type: 'text', text: 'Cancelled.' }] };
658
- }
659
- issueKey = pickerResult.content?.ticket;
660
- if (!issueKey || issueKey === '__cancel__') {
661
- return { content: [{ type: 'text', text: 'Cancelled.' }] };
662
- }
663
- }
664
- catch {
665
- // Client doesn't support elicitation — list options and ask caller to retry with issueKey
666
- const list = candidates.map((c) => ` • ${c.key} — ${c.summary} (${c.status})`).join('\n');
667
- return {
668
- content: [{
669
- type: 'text',
670
- text: `Found ${candidates.length} tickets matching "${a.query}":\n${list}\n\nRe-run start_work with the desired issueKey.`,
671
- }],
672
- };
673
- }
674
- }
675
- }
676
- if (!issueKey)
677
- throw new Error('Could not resolve issue key.');
678
- const fields = await jira.getIssueFields(issueKey);
679
- const branchName = a.branchName ?? slugifyBranchName(issueKey, fields.summary, fields.type);
680
- const repoPath = a.repoPath ?? process.cwd();
681
- // Check if branch already exists on remote before creating
682
- const remote = checkRemoteBranch(branchName, repoPath);
683
- if (remote.exists) {
684
- const authorLine = remote.author ? `Last author: ${remote.author}` : null;
685
- const commitLine = remote.date
686
- ? `Last commit: ${remote.date} — ${remote.message ?? ''}${remote.sha ? ` (${remote.sha})` : ''}`
687
- : null;
688
- const contextLines = [authorLine, commitLine].filter(Boolean).join('\n');
689
- const message = [
690
- `Branch "${branchName}" already exists on remote.`,
691
- `Ticket: ${issueKey} — ${fields.summary}`,
692
- contextLines,
693
- ].filter(Boolean).join('\n');
694
- try {
695
- const result = await server.elicitInput({
696
- message,
697
- requestedSchema: {
698
- type: 'object',
699
- properties: {
700
- action: {
701
- type: 'string',
702
- title: 'What would you like to do?',
703
- oneOf: [
704
- { const: 'checkout', title: `Check out existing branch "${branchName}"` },
705
- { const: 'new_name', title: 'Use a different branch name (re-run start_work with branchName)' },
706
- { const: 'cancel', title: 'Cancel' },
707
- ],
708
- },
709
- },
710
- required: ['action'],
711
- },
712
- });
713
- if (result.action === 'cancel' || result.action === 'decline') {
714
- return { content: [{ type: 'text', text: 'Cancelled.' }] };
715
- }
716
- if (result.action === 'accept') {
717
- const chosen = result.content?.action;
718
- if (chosen === 'checkout') {
719
- const checkout = checkoutRemoteBranch(branchName, repoPath);
720
- return { content: [{ type: 'text', text: `${message}\n\n${checkout.content[0].text}` }] };
721
- }
722
- if (chosen === 'cancel') {
723
- return { content: [{ type: 'text', text: 'Cancelled.' }] };
724
- }
725
- return {
726
- content: [{
727
- type: 'text',
728
- text: `${message}\n\nRe-run start_work with a custom branchName to proceed.`,
729
- }],
730
- };
731
- }
732
- return { content: [{ type: 'text', text: 'Cancelled.' }] };
733
- }
734
- catch {
735
- return {
736
- content: [{
737
- type: 'text',
738
- text: [
739
- message,
740
- '',
741
- 'Options:',
742
- ` • Check out existing: git checkout --track origin/${branchName}`,
743
- ` • Use a different name: re-run start_work with branchName set`,
744
- ].join('\n'),
745
- }],
746
- };
747
- }
748
- }
749
- const branchResult = createBranch({
750
- branchName,
751
- baseBranch: a.baseBranch,
752
- repoPath,
753
- push: a.push ?? false,
754
- });
755
- const lines = [
756
- `Ticket: ${issueKey} — ${fields.summary}`,
757
- `Status: ${fields.status}`,
758
- branchResult.content[0].text,
759
- ];
760
- if (a.transitionName) {
761
- try {
762
- await jira.mutateIssue({ issueKey, transitionName: a.transitionName });
763
- lines.push(`Jira: transitioned → ${a.transitionName}`);
764
- }
765
- catch (err) {
766
- lines.push(`Jira: could not transition — ${err.message}`);
767
- }
768
- }
769
- // Fetch README from Bitbucket for project conventions
770
- if (bitbucket) {
771
- try {
772
- const remoteUrl = (() => {
773
- try {
774
- return execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: repoPath, encoding: 'utf-8' }).trim();
775
- }
776
- catch {
777
- return '';
778
- }
779
- })();
780
- const parsed = parseBitbucketRemote(remoteUrl);
781
- if (parsed) {
782
- const readme = await bitbucket.fetchFileText(parsed.projectKey, parsed.repoSlug, 'README.md');
783
- if (readme) {
784
- const maxLen = 4000;
785
- const truncated = readme.length > maxLen ? readme.slice(0, maxLen) + '\n... (truncated)' : readme;
786
- lines.push('');
787
- lines.push('Project conventions (from README.md):');
788
- lines.push('────────────────────────────────────');
789
- lines.push(truncated);
790
- lines.push('────────────────────────────────────');
791
- lines.push('Follow the conventions above when writing commit messages and the PR description.');
792
- }
793
- }
794
- }
795
- catch { /* README fetch is best-effort */ }
796
- lines.push('');
797
- lines.push('Next steps:');
798
- lines.push(' 1. Make your changes and commit following the project conventions.');
799
- lines.push(' 2. Use bitbucket_mutate (create) to open a PR — the Jira summary and ticket key make a good title/description starting point.');
800
- lines.push(' 3. Add reviewers: bitbucket_search resource=users to find colleagues, or pass pickReviewers=true in create to get an interactive picker.');
801
- }
802
- return { content: [{ type: 'text', text: lines.join('\n') }] };
803
- }
804
- // Jira
805
- case 'jira_search': {
806
- if (!jira)
807
- throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
808
- const a = args;
809
- const resource = a.resource ?? 'issues';
810
- if (resource === 'projects')
811
- return await jira.getProjects({ maxResults: a.maxResults });
812
- if (resource === 'issue_types')
813
- return await jira.getIssueTypes({ projectKey: a.projectKey ?? a.project });
814
- if (resource === 'boards')
815
- return await jira.getBoards({ projectKey: a.projectKey ?? a.project, maxResults: a.maxResults, startAt: a.startAt });
816
- if (resource === 'sprints')
817
- return await jira.getSprints({ boardId: a.boardId, state: a.sprintState, maxResults: a.maxResults, startAt: a.startAt });
818
- if (resource === 'board_overview')
819
- return await jira.boardOverview({ boardId: a.boardId, sprintState: a.sprintState, sprintMaxResults: a.maxResults, sprintStartAt: a.startAt, includeIssues: a.includeIssues, assignee: a.assignee, status: a.status });
820
- if (resource === 'versions')
821
- return await jira.listVersions({ projectKey: a.projectKey ?? a.project, query: a.query, maxResults: a.maxResults });
822
- if (resource === 'users')
823
- return await jira.searchUsers({ query: a.query ?? '', maxResults: a.maxResults });
824
- // issues (default)
825
- if (a.mine)
826
- return await jira.myIssues({ maxResults: a.maxResults, startAt: a.startAt });
827
- return await jira.searchIssues(a);
828
- }
829
- case 'jira_get':
830
- if (!jira)
831
- throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
832
- return await jira.issueOverview(args);
833
- case 'jira_mutate':
834
- if (!jira)
835
- throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
836
- return await jira.mutateIssue(normalizeJiraMutateArgs(args));
837
- case 'jira_get_attachment': {
838
- if (!jira)
839
- throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
840
- const a = args;
841
- validateAttachmentArgs(a);
842
- return await jira.getAttachment(a);
843
- }
844
- case 'jira_comment': {
845
- if (!jira)
846
- throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
847
- const a = normalizeJiraProjectArgs(args);
848
- const action = a.action ?? 'add';
849
- if (action === 'update')
850
- return await jira.editComment({ issueKey: a.issueKey, commentId: a.commentId, body: a.body });
851
- if (action === 'delete')
852
- return await jira.deleteComment({ issueKey: a.issueKey, commentId: a.commentId });
853
- return await jira.addComment({ issueKey: a.issueKey, body: a.body });
854
- }
855
- case 'jira_version': {
856
- if (!jira)
857
- throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
858
- const a = normalizeJiraProjectArgs(args);
859
- return await jira.mutateVersion(a);
860
- }
861
- // Bitbucket
862
- case 'bitbucket_search': {
863
- if (!bitbucket)
864
- throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
865
- const a = normalizeBitbucketArgs(args);
866
- const resource = a.resource ?? 'pull_requests';
867
- if (resource === 'repos')
868
- return await bitbucket.listRepos(a);
869
- if (resource === 'branches')
870
- return await bitbucket.getBranches(a);
871
- if (resource === 'users')
872
- return await bitbucket.searchUsers({ projectKey: a.projectKey, repoSlug: a.repoSlug, query: a.query, limit: a.limit, start: a.start });
873
- // pull_requests (default)
874
- if (a.mine)
875
- return await bitbucket.myPrs({ limit: a.limit, start: a.start, role: a.role });
876
- return await bitbucket.listPullRequests(a);
877
- }
878
- case 'bitbucket_get_pr':
879
- if (!bitbucket)
880
- throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
881
- return await bitbucket.getPrOverview(normalizeBitbucketArgs(args));
882
- case 'bitbucket_mutate': {
883
- if (!bitbucket)
884
- throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
885
- const a = normalizeBitbucketArgs(args);
886
- const action = a.action;
887
- if (action === 'approve')
888
- return await bitbucket.approvePr(a);
889
- if (action === 'unapprove')
890
- return await bitbucket.unapprovePr(a);
891
- if (action === 'needs_work')
892
- return await bitbucket.needsWorkPr(a);
893
- if (action === 'decline')
894
- return await bitbucket.declinePr({ ...a, message: a.declineMessage });
895
- if (action === 'merge')
896
- return await bitbucket.mergePr({ ...a, message: a.mergeMessage, mergeStrategy: a.mergeStrategy });
897
- // Handle interactive reviewer picker for PR creation
898
- const createArgs = a.create;
899
- if (createArgs?.pickReviewers && !a.prId) {
900
- const projectKey = a.projectKey;
901
- const repoSlug = a.repoSlug;
902
- const users = await bitbucket.searchUsersRaw({ projectKey, repoSlug, limit: 30 });
903
- if (users.length > 0) {
904
- // Build boolean checkbox schema — one field per user
905
- // key must be safe for JSON schema property names
906
- const toSchemaKey = (uname) => uname.replace(/[^a-zA-Z0-9]/g, '_');
907
- const userMap = new Map(); // schemaKey -> username
908
- const properties = {};
909
- for (const u of users) {
910
- const key = toSchemaKey(u.name);
911
- userMap.set(key, u.name);
912
- properties[key] = { type: 'boolean', title: `${u.displayName} (${u.name})` };
913
- }
914
- try {
915
- const pickerResult = await server.elicitInput({
916
- message: 'Select reviewers to add to this PR:',
917
- requestedSchema: { type: 'object', properties },
918
- });
919
- if (pickerResult.action === 'accept' && pickerResult.content) {
920
- const selected = [];
921
- for (const [key, username] of userMap) {
922
- if (pickerResult.content[key] === true)
923
- selected.push(username);
924
- }
925
- createArgs.reviewers = selected;
926
- }
927
- }
928
- catch {
929
- // elicitation not supported — proceed without reviewer picker
930
- }
931
- }
932
- }
933
- return await bitbucket.mutatePullRequest(a);
934
- }
935
- case 'bitbucket_comment': {
936
- if (!bitbucket)
937
- throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
938
- const a = normalizeBitbucketArgs(args);
939
- const action = a.action ?? 'add';
940
- if (action === 'update')
941
- return await bitbucket.updatePrComment(a);
942
- if (action === 'delete')
943
- return await bitbucket.deletePrComment(a);
944
- return await bitbucket.addPrComment(a);
945
- }
946
- case 'bitbucket_get_file':
947
- if (!bitbucket)
948
- throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
949
- return await bitbucket.getFile(normalizeBitbucketArgs(args));
950
- case 'bitbucket_get_attachment': {
951
- if (!bitbucket)
952
- throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
953
- const normalized = normalizeBitbucketArgs(args);
954
- validateAttachmentArgs(normalized);
955
- return await bitbucket.getAttachment(normalized);
956
- }
957
- case 'bitbucket_pr_tasks': {
958
- if (!bitbucket)
959
- throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
960
- const a = normalizeBitbucketArgs(args);
961
- const action = (a.action ?? 'list');
962
- if (action === 'list')
963
- return await bitbucket.getPrTasks(a);
964
- return await bitbucket.mutatePrTask({ ...a, action: action });
965
- }
966
- case 'complete_work': {
967
- if (!jira || !bitbucket)
968
- throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
969
- const a = args;
970
- const repoPath = a.repoPath ?? process.cwd();
971
- const lines = [];
972
- // Resolve PR — by prId or current branch
973
- let resolvedPrId = a.prId;
974
- if (resolvedPrId === undefined) {
975
- const branch = (() => { try {
976
- return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: repoPath, encoding: 'utf-8' }).trim();
977
- }
978
- catch {
979
- return '';
980
- } })();
981
- if (!branch || branch === 'HEAD') {
982
- throw new Error('Could not determine current branch. Provide prId or run from a checked-out branch.');
983
- }
984
- const remote = (() => { try {
985
- return execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: repoPath, encoding: 'utf-8' }).trim();
986
- }
987
- catch {
988
- return '';
989
- } })();
990
- const parsed = parseBitbucketRemote(remote);
991
- if (!parsed)
992
- throw new Error('Could not parse Bitbucket remote URL. Provide projectKey/repoSlug explicitly.');
993
- const projectKey = a.projectKey ?? parsed.projectKey;
994
- const repoSlug = a.repoSlug ?? parsed.repoSlug;
995
- const pr = await bitbucket.findOpenPrForBranch(projectKey, repoSlug, branch);
996
- if (!pr)
997
- throw new Error(`No open PR found for branch "${branch}". Provide prId explicitly.`);
998
- resolvedPrId = pr.id;
999
- lines.push(`Branch: ${branch} → PR #${resolvedPrId}`);
1000
- // Auto-detect Jira issue key from branch if not provided
1001
- if (!a.issueKey) {
1002
- const JIRA_KEY_RE = /\b([A-Z][A-Z0-9]+-\d+)\b/;
1003
- const match = branch.match(JIRA_KEY_RE);
1004
- if (match) {
1005
- a.issueKey = match[1];
1006
- lines.push(`Jira: auto-detected ${a.issueKey} from branch name`);
1007
- }
1008
- }
1009
- }
1010
- // Merge the PR
1011
- const mergeResult = await bitbucket.mergePr({
1012
- prId: resolvedPrId,
1013
- projectKey: a.projectKey,
1014
- repoSlug: a.repoSlug,
1015
- mergeStrategy: a.mergeStrategy,
1016
- message: a.mergeMessage,
1017
- });
1018
- lines.push(mergeResult.content[0].text);
1019
- // Transition Jira ticket
1020
- if (!a.skipJiraTransition && a.issueKey) {
1021
- const transitionName = a.transitionName ?? 'Done';
1022
- try {
1023
- await jira.mutateIssue({ issueKey: a.issueKey, transitionName });
1024
- lines.push(`Jira: ${a.issueKey} transitioned → ${transitionName}`);
1025
- }
1026
- catch (err) {
1027
- lines.push(`Jira: could not transition ${a.issueKey} — ${err.message}`);
1028
- }
1029
- }
1030
- else if (!a.skipJiraTransition) {
1031
- lines.push('Jira: no issue key — skipped transition (provide issueKey to transition)');
1032
- }
1033
- return { content: [{ type: 'text', text: lines.join('\n') }] };
1034
- }
1035
- default:
1036
- throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
1037
- }
1038
- }
1039
- catch (err) {
1040
- if (err instanceof McpError)
1041
- throw err;
1042
- return {
1043
- content: [{ type: 'text', text: `Error: ${err.message}` }],
1044
- isError: true,
1045
- };
1046
- }
1047
- });
1048
- async function shutdown() {
1049
- await server.close();
1050
- process.exit(0);
1051
- }
1052
- process.on('SIGINT', shutdown);
1053
- process.on('SIGTERM', shutdown);
1054
- const transport = new StdioServerTransport();
1055
- await server.connect(transport);