cli / mcp for bitbucket
1import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
3import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
5import { registerAllTools } from '../../tools';
6
7vi.mock('@bitbucket-tool/core', () => ({
8 listPullRequests: vi.fn(),
9 getPullRequest: vi.fn(),
10 createPullRequest: vi.fn(),
11 updatePullRequest: vi.fn(),
12 declinePullRequest: vi.fn(),
13 getPullRequestComments: vi.fn(),
14 addComment: vi.fn(),
15 getPullRequestDiff: vi.fn(),
16}));
17
18import {
19 addComment,
20 createPullRequest,
21 declinePullRequest,
22 getPullRequest,
23 getPullRequestComments,
24 getPullRequestDiff,
25 listPullRequests,
26 updatePullRequest,
27} from '@bitbucket-tool/core';
28
29const ws = 'test-workspace';
30const repo = 'test-repo';
31const prId = 42;
32
33const callTool = async (client: Client, name: string, args: Record<string, unknown>) =>
34 client.callTool({ name, arguments: args });
35
36const textOf = (result: Awaited<ReturnType<Client['callTool']>>) =>
37 (result.content as Array<{ type: string; text: string }>)[0].text;
38
39describe('PR tools', () => {
40 let client: Client;
41 let server: McpServer;
42
43 beforeAll(async () => {
44 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
45 server = new McpServer({ name: 'test', version: '0.0.1' });
46 client = new Client({ name: 'test-client', version: '0.0.1' });
47
48 registerAllTools(server);
49
50 await server.connect(serverTransport);
51 await client.connect(clientTransport);
52 });
53
54 afterAll(async () => {
55 await client.close();
56 await server.close();
57 });
58
59 beforeEach(() => {
60 vi.clearAllMocks();
61 });
62
63 describe('list_pull_requests', () => {
64 it('passes params to core and returns JSON response', async () => {
65 const prs = [{ id: 1, title: 'First PR' }];
66 vi.mocked(listPullRequests).mockResolvedValue({ ok: true, data: prs as never });
67
68 const result = await callTool(client, 'list_pull_requests', {
69 workspace: ws,
70 repo_slug: repo,
71 state: 'OPEN',
72 });
73
74 expect(listPullRequests).toHaveBeenCalledWith({
75 workspace: ws,
76 repoSlug: repo,
77 state: 'OPEN',
78 page: undefined,
79 pagelen: undefined,
80 });
81 expect(result.isError).toBeFalsy();
82 expect(JSON.parse(textOf(result))).toEqual(prs);
83 });
84
85 it('returns error response on failure', async () => {
86 vi.mocked(listPullRequests).mockResolvedValue({
87 ok: false,
88 error: { status: 401, message: 'Unauthorized' },
89 });
90
91 const result = await callTool(client, 'list_pull_requests', {
92 workspace: ws,
93 repo_slug: repo,
94 });
95
96 expect(result.isError).toBe(true);
97 expect(textOf(result)).toBe('Unauthorized (401)');
98 });
99 });
100
101 describe('get_pull_request', () => {
102 it('passes params to core and returns JSON response', async () => {
103 const pr = { id: prId, title: 'My PR' };
104 vi.mocked(getPullRequest).mockResolvedValue({ ok: true, data: pr as never });
105
106 const result = await callTool(client, 'get_pull_request', {
107 workspace: ws,
108 repo_slug: repo,
109 pull_request_id: prId,
110 });
111
112 expect(getPullRequest).toHaveBeenCalledWith({
113 workspace: ws,
114 repoSlug: repo,
115 prId,
116 });
117 expect(JSON.parse(textOf(result))).toEqual(pr);
118 });
119 });
120
121 describe('create_pull_request', () => {
122 it('passes params with default destination branch', async () => {
123 const pr = { id: 1, title: 'New PR' };
124 vi.mocked(createPullRequest).mockResolvedValue({ ok: true, data: pr as never });
125
126 await callTool(client, 'create_pull_request', {
127 workspace: ws,
128 repo_slug: repo,
129 source_branch: 'feat/cool',
130 title: 'New PR',
131 });
132
133 expect(createPullRequest).toHaveBeenCalledWith({
134 workspace: ws,
135 repoSlug: repo,
136 sourceBranch: 'feat/cool',
137 destinationBranch: 'main',
138 title: 'New PR',
139 description: undefined,
140 });
141 });
142
143 it('uses custom destination branch when provided', async () => {
144 vi.mocked(createPullRequest).mockResolvedValue({
145 ok: true,
146 data: { id: 1 } as never,
147 });
148
149 await callTool(client, 'create_pull_request', {
150 workspace: ws,
151 repo_slug: repo,
152 source_branch: 'feat/cool',
153 destination_branch: 'develop',
154 title: 'New PR',
155 description: 'Some description',
156 });
157
158 expect(createPullRequest).toHaveBeenCalledWith({
159 workspace: ws,
160 repoSlug: repo,
161 sourceBranch: 'feat/cool',
162 destinationBranch: 'develop',
163 title: 'New PR',
164 description: 'Some description',
165 });
166 });
167 });
168
169 describe('update_pull_request', () => {
170 it('builds updates object from optional fields', async () => {
171 vi.mocked(updatePullRequest).mockResolvedValue({
172 ok: true,
173 data: { id: prId } as never,
174 });
175
176 await callTool(client, 'update_pull_request', {
177 workspace: ws,
178 repo_slug: repo,
179 pull_request_id: prId,
180 title: 'Updated title',
181 destination_branch: 'develop',
182 });
183
184 expect(updatePullRequest).toHaveBeenCalledWith({
185 workspace: ws,
186 repoSlug: repo,
187 prId,
188 updates: {
189 title: 'Updated title',
190 destination: { branch: { name: 'develop' } },
191 },
192 });
193 });
194
195 it('omits undefined fields from updates', async () => {
196 vi.mocked(updatePullRequest).mockResolvedValue({
197 ok: true,
198 data: { id: prId } as never,
199 });
200
201 await callTool(client, 'update_pull_request', {
202 workspace: ws,
203 repo_slug: repo,
204 pull_request_id: prId,
205 title: 'Only title',
206 });
207
208 expect(updatePullRequest).toHaveBeenCalledWith({
209 workspace: ws,
210 repoSlug: repo,
211 prId,
212 updates: { title: 'Only title' },
213 });
214 });
215 });
216
217 describe('decline_pull_request', () => {
218 it('passes params to core', async () => {
219 vi.mocked(declinePullRequest).mockResolvedValue({ ok: true, data: undefined as never });
220
221 const result = await callTool(client, 'decline_pull_request', {
222 workspace: ws,
223 repo_slug: repo,
224 pull_request_id: prId,
225 });
226
227 expect(declinePullRequest).toHaveBeenCalledWith({
228 workspace: ws,
229 repoSlug: repo,
230 prId,
231 });
232 expect(result.isError).toBeFalsy();
233 expect(textOf(result)).toBe('Pull request declined.');
234 });
235 });
236
237 describe('get_pull_request_comments', () => {
238 it('passes params including pagination to core', async () => {
239 const comments = [{ id: 1, content: { raw: 'LGTM' } }];
240 vi.mocked(getPullRequestComments).mockResolvedValue({
241 ok: true,
242 data: comments as never,
243 });
244
245 const result = await callTool(client, 'get_pull_request_comments', {
246 workspace: ws,
247 repo_slug: repo,
248 pull_request_id: prId,
249 page: 2,
250 pagelen: 10,
251 });
252
253 expect(getPullRequestComments).toHaveBeenCalledWith({
254 workspace: ws,
255 repoSlug: repo,
256 prId,
257 page: 2,
258 pagelen: 10,
259 });
260 expect(JSON.parse(textOf(result))).toEqual(comments);
261 });
262 });
263
264 describe('add_pull_request_comment', () => {
265 it('passes params to core', async () => {
266 vi.mocked(addComment).mockResolvedValue({ ok: true, data: undefined as never });
267
268 const result = await callTool(client, 'add_pull_request_comment', {
269 workspace: ws,
270 repo_slug: repo,
271 pull_request_id: prId,
272 content: 'Nice work!',
273 });
274
275 expect(addComment).toHaveBeenCalledWith({
276 workspace: ws,
277 repoSlug: repo,
278 prId,
279 content: 'Nice work!',
280 });
281 expect(result.isError).toBeFalsy();
282 expect(textOf(result)).toBe('Comment added.');
283 });
284 });
285
286 describe('get_pull_request_diff', () => {
287 it('returns raw text diff (not JSON)', async () => {
288 const diff = '--- a/file.ts\n+++ b/file.ts\n@@ -1 +1 @@\n-old\n+new';
289 vi.mocked(getPullRequestDiff).mockResolvedValue({ ok: true, data: diff });
290
291 const result = await callTool(client, 'get_pull_request_diff', {
292 workspace: ws,
293 repo_slug: repo,
294 pull_request_id: prId,
295 });
296
297 expect(textOf(result)).toBe(diff);
298 });
299
300 it('returns error on failure', async () => {
301 vi.mocked(getPullRequestDiff).mockResolvedValue({
302 ok: false,
303 error: { status: 404, message: 'PR not found' },
304 });
305
306 const result = await callTool(client, 'get_pull_request_diff', {
307 workspace: ws,
308 repo_slug: repo,
309 pull_request_id: 999,
310 });
311
312 expect(result.isError).toBe(true);
313 expect(textOf(result)).toBe('PR not found (404)');
314 });
315 });
316});