mcp-server-bitbucket 0.11.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,113 @@
1
+ /** SPEC-4: actual incomplete HTTP bodies and MCP process survival. */
2
+ import axios, { type AxiosError } from 'axios';
3
+ import { afterEach, beforeEach, expect, it, vi } from 'vitest';
4
+ import { createServer, type Server, type ServerResponse } from 'node:http';
5
+ import { once } from 'node:events';
6
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import { fileURLToPath, pathToFileURL } from 'node:url';
10
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
11
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
12
+ import { BitbucketClient } from '../src/client.js';
13
+ import { resetSettings } from '../src/settings.js';
14
+
15
+ vi.mock('../src/utils.js', async (importOriginal) => ({
16
+ ...await importOriginal<typeof import('../src/utils.js')>(), sleep: vi.fn(async () => undefined),
17
+ }));
18
+
19
+ let server: Server | undefined;
20
+
21
+ function abortBody(response: ServerResponse): void {
22
+ response.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': '1000' });
23
+ response.flushHeaders();
24
+ response.write('{"incomplete":');
25
+ setTimeout(() => response.destroy(), 20);
26
+ }
27
+
28
+ async function listen(handler: Parameters<typeof createServer>[0]): Promise<string> {
29
+ server = createServer(handler);
30
+ server.listen(0, '127.0.0.1');
31
+ await once(server, 'listening');
32
+ const address = server.address();
33
+ if (!address || typeof address === 'string') throw new Error('Missing loopback address');
34
+ return `http://127.0.0.1:${address.port}`;
35
+ }
36
+
37
+ beforeEach(() => {
38
+ vi.stubEnv('BITBUCKET_WORKSPACE', 'synthetic');
39
+ vi.stubEnv('BITBUCKET_EMAIL', 'test@example.com');
40
+ vi.stubEnv('BITBUCKET_API_TOKEN', 'synthetic-no-provider-call');
41
+ vi.stubEnv('MAX_RETRIES', '1');
42
+ resetSettings();
43
+ });
44
+
45
+ afterEach(async () => {
46
+ vi.restoreAllMocks();
47
+ vi.unstubAllEnvs();
48
+ resetSettings();
49
+ if (server) {
50
+ server.closeAllConnections();
51
+ await new Promise<void>(resolve => server!.close(() => resolve()));
52
+ server = undefined;
53
+ }
54
+ });
55
+
56
+ it.each(['json', 'text'])('retries actual aborted %s GET response bodies, but never repeats an aborted write', async format => {
57
+ let reads = 0;
58
+ let writes = 0;
59
+ const observed: AxiosError[] = [];
60
+ const url = await listen((request, response) => {
61
+ if (request.method === 'POST') { writes++; abortBody(response); }
62
+ else if (++reads === 1) abortBody(response);
63
+ else { response.setHeader('Content-Type', 'application/json'); response.end('{"name":"recovered"}'); }
64
+ });
65
+ const originalCreate = axios.create.bind(axios);
66
+ vi.spyOn(axios, 'create').mockImplementation(config => {
67
+ const instance = originalCreate({ ...config, baseURL: url, proxy: false });
68
+ instance.interceptors.response.use(undefined, error => { observed.push(error); return Promise.reject(error); });
69
+ return instance;
70
+ });
71
+ const client = new BitbucketClient();
72
+ if (format === 'json') expect(await client.getRepository('repo')).toMatchObject({ name: 'recovered' });
73
+ else expect(await client.getCommitDiff('repo', 'base', 'head')).toBe('{"name":"recovered"}');
74
+ expect(reads).toBe(2);
75
+ expect(observed[0].code).toBe('ERR_BAD_RESPONSE');
76
+ expect(observed[0].message).toBe('stream has been aborted');
77
+ expect(observed[0].response?.status).toBe(200);
78
+ await expect(client.createTag('repo', 'v1', 'main')).rejects.toThrow(/Network/);
79
+ expect(writes).toBe(1);
80
+ }, 10_000);
81
+
82
+ it('reports exhausted HTTP disconnects, then completes a tool call in the same stdio subprocess', async () => {
83
+ let calls = 0;
84
+ const url = await listen((_request, response) => {
85
+ if (++calls <= 2) abortBody(response);
86
+ else { response.setHeader('Content-Type', 'application/json'); response.end('{"values":[{"hash":"recovered","message":"ok"}]}'); }
87
+ });
88
+ const directory = await mkdtemp(join(tmpdir(), 'bitbucket-stdio-recovery-'));
89
+ const preloader = join(directory, 'loopback.mjs');
90
+ const axiosModule = pathToFileURL(fileURLToPath(new URL('../node_modules/axios/index.js', import.meta.url))).href;
91
+ await writeFile(preloader, `import axios from ${JSON.stringify(axiosModule)};\nconst create = axios.create.bind(axios);\naxios.create = config => create({...config, baseURL:${JSON.stringify(url)}, proxy:false});\n`);
92
+ const client = new Client({ name: 'synthetic-recovery', version: '1.0.0' });
93
+ const transport = new StdioClientTransport({
94
+ command: process.execPath,
95
+ args: ['--import', preloader, fileURLToPath(new URL('../dist/index.js', import.meta.url))],
96
+ env: { BITBUCKET_WORKSPACE: 'synthetic', BITBUCKET_EMAIL: 'test@example.com', BITBUCKET_API_TOKEN: 'synthetic-no-provider-call',
97
+ MAX_RETRIES: '1', API_TIMEOUT: '5', MCP_TRANSPORT: 'stdio' },
98
+ });
99
+ try {
100
+ await client.connect(transport);
101
+ const failed = await client.callTool({ name: 'list_commits', arguments: { repo_slug: 'repo' } });
102
+ expect(failed.isError).toBe(true);
103
+ expect(JSON.stringify(failed.content)).toContain('Network request failed after 2 attempt(s)');
104
+ expect(calls).toBe(2);
105
+ const succeeded = await client.callTool({ name: 'list_commits', arguments: { repo_slug: 'repo' } });
106
+ expect(succeeded.isError).not.toBe(true);
107
+ expect(JSON.stringify(succeeded.content)).toContain('recover');
108
+ expect(calls).toBe(3);
109
+ } finally {
110
+ await client.close();
111
+ await rm(directory, { recursive: true, force: true });
112
+ }
113
+ }, 15_000);
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Tests for the OAuth proxy.
3
+ *
4
+ * The proxy exists because Entra and the MCP spec disagree about the `resource`
5
+ * parameter (AADSTS9010010), so the tests pin down exactly what gets rewritten
6
+ * on the way through — and what must not be touched: PKCE, state, and the
7
+ * redirect the user is sent back to.
8
+ */
9
+
10
+ import { describe, it, expect } from 'vitest';
11
+ import {
12
+ AUTHORIZE_PATH,
13
+ isAllowedRedirect,
14
+ registerClient,
15
+ REGISTER_PATH,
16
+ buildAuthorizeRedirect,
17
+ buildTokenRequestBody,
18
+ forwardTokenRequest,
19
+ getProxyConfig,
20
+ proxyAuthorizationServerMetadata,
21
+ redactUrlForLog,
22
+ TOKEN_PATH,
23
+ type ProxyConfig,
24
+ } from '../src/oauth-proxy.js';
25
+ import type { AuthConfig } from '../src/auth.js';
26
+
27
+ const ORIGIN = 'https://mcp.example.com';
28
+ const UPSTREAM_AUTHORIZE = 'https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize';
29
+ const UPSTREAM_TOKEN = 'https://login.microsoftonline.com/tenant/oauth2/v2.0/token';
30
+ const CLAUDE_CALLBACK = 'https://claude.ai/api/mcp/auth_callback';
31
+
32
+ const auth = {
33
+ issuer: 'https://login.microsoftonline.com/tenant/v2.0',
34
+ audience: 'f310dc92',
35
+ resourceUrl: `${ORIGIN}/mcp`,
36
+ resourceIdentifier: `${ORIGIN}/mcp`,
37
+ advertisedScopes: ['api://f310dc92/mcp.access'],
38
+ proxyAuthorizationServerMetadata: true,
39
+ jwks: (() => {
40
+ throw new Error('not used');
41
+ }) as unknown as AuthConfig['jwks'],
42
+ } as AuthConfig;
43
+
44
+ function proxy(overrides: Partial<ProxyConfig> = {}): ProxyConfig {
45
+ return { ...getProxyConfig(auth, { MCP_OAUTH_CLIENT_ID: 'client-from-entra' }), ...overrides };
46
+ }
47
+
48
+ function authorizeUrl(params: Record<string, string>): URL {
49
+ const url = new URL(`${ORIGIN}${AUTHORIZE_PATH}`);
50
+ for (const [key, value] of Object.entries(params)) {
51
+ url.searchParams.set(key, value);
52
+ }
53
+ return url;
54
+ }
55
+
56
+ describe('configuration', () => {
57
+ it('requests the advertised scope plus offline_access upstream', () => {
58
+ expect(getProxyConfig(auth, {}).upstreamScope).toBe('api://f310dc92/mcp.access offline_access');
59
+ });
60
+
61
+ it('accepts an explicit upstream scope', () => {
62
+ const cfg = getProxyConfig(auth, { MCP_OAUTH_UPSTREAM_SCOPE: 'api://other/scope offline_access' });
63
+
64
+ expect(cfg.upstreamScope).toBe('api://other/scope offline_access');
65
+ });
66
+
67
+ it("defaults the redirect allowlist to Claude's callbacks", () => {
68
+ expect(getProxyConfig(auth, {}).redirectAllowlist).toContain(CLAUDE_CALLBACK);
69
+ });
70
+ });
71
+
72
+ describe('authorization server metadata', () => {
73
+ it('presents this server as the authorization server', () => {
74
+ const meta = proxyAuthorizationServerMetadata(auth, proxy());
75
+
76
+ expect(meta).toMatchObject({
77
+ issuer: ORIGIN,
78
+ authorization_endpoint: `${ORIGIN}${AUTHORIZE_PATH}`,
79
+ token_endpoint: `${ORIGIN}${TOKEN_PATH}`,
80
+ code_challenge_methods_supported: ['S256'],
81
+ });
82
+ });
83
+ });
84
+
85
+ describe('authorize', () => {
86
+ const base = {
87
+ client_id: 'client-123',
88
+ response_type: 'code',
89
+ redirect_uri: CLAUDE_CALLBACK,
90
+ state: 'opaque-state',
91
+ code_challenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
92
+ code_challenge_method: 'S256',
93
+ };
94
+
95
+ it('redirects upstream, passing PKCE and state through untouched', () => {
96
+ const result = buildAuthorizeRedirect(authorizeUrl(base), proxy(), UPSTREAM_AUTHORIZE);
97
+
98
+ expect(result.status).toBe(302);
99
+ if (result.status !== 302) return;
100
+ const location = new URL(result.location);
101
+
102
+ expect(location.origin + location.pathname).toBe(UPSTREAM_AUTHORIZE);
103
+ expect(location.searchParams.get('code_challenge')).toBe(base.code_challenge);
104
+ expect(location.searchParams.get('code_challenge_method')).toBe('S256');
105
+ expect(location.searchParams.get('state')).toBe(base.state);
106
+ expect(location.searchParams.get('redirect_uri')).toBe(CLAUDE_CALLBACK);
107
+ expect(location.searchParams.get('client_id')).toBe(base.client_id);
108
+ });
109
+
110
+ it('swaps the scope for the one the upstream expects', () => {
111
+ const result = buildAuthorizeRedirect(
112
+ authorizeUrl({ ...base, scope: `${ORIGIN}/mcp` }),
113
+ proxy(),
114
+ UPSTREAM_AUTHORIZE
115
+ );
116
+
117
+ expect(result.status).toBe(302);
118
+ if (result.status !== 302) return;
119
+
120
+ expect(new URL(result.location).searchParams.get('scope')).toBe(
121
+ 'api://f310dc92/mcp.access offline_access'
122
+ );
123
+ });
124
+
125
+ it('drops the resource parameter, which is what the upstream rejects', () => {
126
+ // AADSTS9010010: "the resource parameter doesn't match with the requested scopes"
127
+ const result = buildAuthorizeRedirect(
128
+ authorizeUrl({ ...base, resource: `${ORIGIN}/mcp` }),
129
+ proxy(),
130
+ UPSTREAM_AUTHORIZE
131
+ );
132
+
133
+ expect(result.status).toBe(302);
134
+ if (result.status !== 302) return;
135
+
136
+ expect(new URL(result.location).searchParams.has('resource')).toBe(false);
137
+ });
138
+
139
+ it('refuses a redirect_uri that is not on the allowlist', () => {
140
+ const result = buildAuthorizeRedirect(
141
+ authorizeUrl({ ...base, redirect_uri: 'https://evil.example.com/steal' }),
142
+ proxy(),
143
+ UPSTREAM_AUTHORIZE
144
+ );
145
+
146
+ expect(result).toMatchObject({ status: 400, error: 'invalid_request' });
147
+ });
148
+
149
+ it('refuses a request with no redirect_uri', () => {
150
+ const { redirect_uri, ...withoutRedirect } = base;
151
+ const result = buildAuthorizeRedirect(authorizeUrl(withoutRedirect), proxy(), UPSTREAM_AUTHORIZE);
152
+
153
+ expect(result).toMatchObject({ status: 400 });
154
+ });
155
+ });
156
+
157
+ describe('token', () => {
158
+ it('forwards the exchange and returns the upstream response verbatim', async () => {
159
+ let seen: { url: string; body: string; auth?: string } | null = null;
160
+ const fetchImpl = (async (url: string, init: RequestInit) => {
161
+ seen = {
162
+ url,
163
+ body: String(init.body),
164
+ auth: (init.headers as Record<string, string>).Authorization,
165
+ };
166
+ return new Response(JSON.stringify({ access_token: 'upstream-token' }), {
167
+ status: 200,
168
+ headers: { 'content-type': 'application/json' },
169
+ });
170
+ }) as unknown as typeof fetch;
171
+
172
+ const result = await forwardTokenRequest(
173
+ 'grant_type=authorization_code&code=abc&code_verifier=xyz&resource=https%3A%2F%2Fmcp.example.com%2Fmcp',
174
+ 'Basic secret',
175
+ UPSTREAM_TOKEN,
176
+ fetchImpl
177
+ );
178
+
179
+ expect(result.status).toBe(200);
180
+ expect(JSON.parse(result.body).access_token).toBe('upstream-token');
181
+ expect(seen!.url).toBe(UPSTREAM_TOKEN);
182
+ expect(seen!.auth).toBe('Basic secret');
183
+ // the exchange itself is untouched…
184
+ expect(seen!.body).toContain('code=abc');
185
+ expect(seen!.body).toContain('code_verifier=xyz');
186
+ // …except for the parameter the upstream rejects
187
+ expect(seen!.body).not.toContain('resource');
188
+ });
189
+
190
+ it('passes upstream errors through instead of masking them', async () => {
191
+ const fetchImpl = (async () =>
192
+ new Response(JSON.stringify({ error: 'invalid_grant' }), {
193
+ status: 400,
194
+ headers: { 'content-type': 'application/json' },
195
+ })) as unknown as typeof fetch;
196
+
197
+ const result = await forwardTokenRequest('grant_type=authorization_code', undefined, UPSTREAM_TOKEN, fetchImpl);
198
+
199
+ expect(result.status).toBe(400);
200
+ expect(JSON.parse(result.body).error).toBe('invalid_grant');
201
+ });
202
+
203
+ it('keeps refresh_token grants working', () => {
204
+ const body = buildTokenRequestBody('grant_type=refresh_token&refresh_token=r1&resource=x');
205
+
206
+ expect(body.get('grant_type')).toBe('refresh_token');
207
+ expect(body.get('refresh_token')).toBe('r1');
208
+ expect(body.has('resource')).toBe(false);
209
+ });
210
+ });
211
+
212
+ describe('logging', () => {
213
+ it('never writes the query string, which carries state and PKCE', () => {
214
+ expect(redactUrlForLog(`${AUTHORIZE_PATH}?state=secret&code_challenge=abc`)).toBe(
215
+ `${AUTHORIZE_PATH}?<redacted>`
216
+ );
217
+ });
218
+
219
+ it('leaves plain paths readable', () => {
220
+ expect(redactUrlForLog('/mcp')).toBe('/mcp');
221
+ });
222
+ });
223
+
224
+
225
+ describe('dynamic client registration', () => {
226
+ // Without this, MCP clients refuse the server outright:
227
+ // "Incompatible auth server: does not support dynamic client registration"
228
+ it('is advertised in the metadata when a client is configured', () => {
229
+ const meta = proxyAuthorizationServerMetadata(auth, proxy());
230
+
231
+ expect(meta.registration_endpoint).toBe(`${ORIGIN}${REGISTER_PATH}`);
232
+ });
233
+
234
+ it('is not advertised when there is no client to hand out', () => {
235
+ const meta = proxyAuthorizationServerMetadata(auth, proxy({ clientId: '' }));
236
+
237
+ expect(meta.registration_endpoint).toBeUndefined();
238
+ });
239
+
240
+ it('returns the existing client, with no secret to leak', () => {
241
+ const result = registerClient({ redirect_uris: [CLAUDE_CALLBACK], client_name: 'Claude' }, proxy());
242
+
243
+ expect(result.status).toBe(201);
244
+ if (result.status !== 201) return;
245
+ expect(result.body.client_id).toBe('client-from-entra');
246
+ expect(result.body.token_endpoint_auth_method).toBe('none');
247
+ expect(result.body.client_secret).toBeUndefined();
248
+ });
249
+
250
+ it('refuses to hand the client out for a redirect we do not accept', () => {
251
+ const result = registerClient({ redirect_uris: ['https://evil.example.com/cb'] }, proxy());
252
+
253
+ expect(result).toMatchObject({ status: 400, error: 'invalid_redirect_uri' });
254
+ });
255
+
256
+ it('refuses a registration with no redirect_uris', () => {
257
+ expect(registerClient({}, proxy())).toMatchObject({ status: 400 });
258
+ });
259
+ });
260
+
261
+
262
+ describe('loopback redirects', () => {
263
+ // MCP clients running on the user's machine listen on an ephemeral port and
264
+ // cannot register it beforehand — RFC 8252 §7.3.
265
+ it('accepts loopback on any port', () => {
266
+ const allowlist = [CLAUDE_CALLBACK];
267
+
268
+ expect(isAllowedRedirect('http://localhost:53682/callback', allowlist)).toBe(true);
269
+ expect(isAllowedRedirect('http://127.0.0.1:9000/cb', allowlist)).toBe(true);
270
+ });
271
+
272
+ it('still refuses remote hosts that are not on the list', () => {
273
+ expect(isAllowedRedirect('https://evil.example.com/cb', [CLAUDE_CALLBACK])).toBe(false);
274
+ expect(isAllowedRedirect('http://evil.example.com/cb', [CLAUDE_CALLBACK])).toBe(false);
275
+ });
276
+
277
+ it('refuses anything that is not a URL', () => {
278
+ expect(isAllowedRedirect('not a url', [CLAUDE_CALLBACK])).toBe(false);
279
+ });
280
+
281
+ it('registers a local client with its loopback redirect', () => {
282
+ const result = registerClient({ redirect_uris: ['http://localhost:53682/callback'] }, proxy());
283
+
284
+ expect(result.status).toBe(201);
285
+ });
286
+ });
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Tests for auth mode resolution (basic vs bearer).
3
+ *
4
+ * Atlassian API tokens (ATATT...) authenticate with Basic auth as `email:token`.
5
+ * Workspace/project/repository access tokens (ATCTT...) only authenticate with
6
+ * `Authorization: Bearer <token>` - Basic auth returns 401 for them.
7
+ */
8
+
9
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
10
+ import { getSettings, resetSettings } from '../src/settings.js';
11
+
12
+ const AUTH_ENV_VARS = [
13
+ 'BITBUCKET_WORKSPACE',
14
+ 'BITBUCKET_EMAIL',
15
+ 'BITBUCKET_API_TOKEN',
16
+ 'BITBUCKET_OAUTH_TOKEN',
17
+ 'BITBUCKET_AUTH_TYPE',
18
+ ];
19
+
20
+ function setEnv(values: Record<string, string>): void {
21
+ for (const key of AUTH_ENV_VARS) {
22
+ delete process.env[key];
23
+ }
24
+ Object.assign(process.env, values);
25
+ resetSettings();
26
+ }
27
+
28
+ describe('auth type resolution', () => {
29
+ const originalEnv = { ...process.env };
30
+
31
+ beforeEach(() => {
32
+ resetSettings();
33
+ });
34
+
35
+ afterEach(() => {
36
+ for (const key of AUTH_ENV_VARS) {
37
+ delete process.env[key];
38
+ if (originalEnv[key] !== undefined) {
39
+ process.env[key] = originalEnv[key];
40
+ }
41
+ }
42
+ resetSettings();
43
+ });
44
+
45
+ it('defaults to basic with email + API token', () => {
46
+ setEnv({
47
+ BITBUCKET_WORKSPACE: 'test-workspace',
48
+ BITBUCKET_EMAIL: 'test@example.com',
49
+ BITBUCKET_API_TOKEN: 'ATATT-personal',
50
+ });
51
+
52
+ const settings = getSettings();
53
+
54
+ expect(settings.bitbucketAuthType).toBe('basic');
55
+ expect(settings.bitbucketApiToken).toBe('ATATT-personal');
56
+ });
57
+
58
+ it('switches to bearer when BITBUCKET_OAUTH_TOKEN is set, preferring that token', () => {
59
+ setEnv({
60
+ BITBUCKET_WORKSPACE: 'test-workspace',
61
+ BITBUCKET_EMAIL: 'test@example.com',
62
+ BITBUCKET_API_TOKEN: 'ATATT-personal',
63
+ BITBUCKET_OAUTH_TOKEN: 'ATCTT-workspace',
64
+ });
65
+
66
+ const settings = getSettings();
67
+
68
+ expect(settings.bitbucketAuthType).toBe('bearer');
69
+ expect(settings.bitbucketApiToken).toBe('ATCTT-workspace');
70
+ });
71
+
72
+ it('uses bearer when a token is configured without an email', () => {
73
+ setEnv({
74
+ BITBUCKET_WORKSPACE: 'test-workspace',
75
+ BITBUCKET_API_TOKEN: 'ATCTT-workspace',
76
+ });
77
+
78
+ expect(getSettings().bitbucketAuthType).toBe('bearer');
79
+ });
80
+
81
+ it('honours an explicit BITBUCKET_AUTH_TYPE', () => {
82
+ setEnv({
83
+ BITBUCKET_WORKSPACE: 'test-workspace',
84
+ BITBUCKET_EMAIL: 'test@example.com',
85
+ BITBUCKET_API_TOKEN: 'ATCTT-workspace',
86
+ BITBUCKET_AUTH_TYPE: 'Bearer',
87
+ });
88
+
89
+ expect(getSettings().bitbucketAuthType).toBe('bearer');
90
+ });
91
+
92
+ it('rejects basic auth without an email', () => {
93
+ setEnv({
94
+ BITBUCKET_WORKSPACE: 'test-workspace',
95
+ BITBUCKET_API_TOKEN: 'ATATT-personal',
96
+ BITBUCKET_AUTH_TYPE: 'basic',
97
+ });
98
+
99
+ expect(() => getSettings()).toThrow(/BITBUCKET_EMAIL is required/);
100
+ });
101
+
102
+ it('rejects a missing token', () => {
103
+ setEnv({
104
+ BITBUCKET_WORKSPACE: 'test-workspace',
105
+ BITBUCKET_EMAIL: 'test@example.com',
106
+ });
107
+
108
+ expect(() => getSettings()).toThrow(/BITBUCKET_API_TOKEN/);
109
+ });
110
+ });
@@ -0,0 +1,20 @@
1
+ import { expect, it } from 'vitest';
2
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
3
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ it('starts the built stdio executable and advertises tools without Bitbucket calls', async () => {
7
+ const client = new Client({ name: 'synthetic-contract', version: '1.0.0' });
8
+ const transport = new StdioClientTransport({
9
+ command: process.execPath,
10
+ args: [fileURLToPath(new URL('../dist/index.js', import.meta.url))],
11
+ env: { BITBUCKET_WORKSPACE: 'synthetic', BITBUCKET_EMAIL: 'test@example.com',
12
+ BITBUCKET_API_TOKEN: 'synthetic-no-provider-call', MCP_TRANSPORT: 'stdio' },
13
+ });
14
+ try {
15
+ await client.connect(transport);
16
+ expect((await client.listTools()).tools.map(t => t.name)).toContain('list_repositories');
17
+ expect((await client.listPrompts()).prompts.length).toBeGreaterThan(0);
18
+ expect((await client.listResources()).resources.length).toBeGreaterThan(0);
19
+ } finally { await client.close(); }
20
+ }, 15_000);
@@ -0,0 +1,22 @@
1
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
3
+
4
+ const url = process.argv[2] || 'http://localhost:3000/mcp';
5
+
6
+ console.log(`Connecting to ${url}...`);
7
+
8
+ const client = new Client({ name: 'test-client', version: '1.0.0' });
9
+ const transport = new StreamableHTTPClientTransport(new URL(url));
10
+
11
+ try {
12
+ await client.connect(transport);
13
+ console.log('Connected!');
14
+
15
+ const tools = await client.listTools();
16
+ console.log(`Found ${tools.tools.length} tools:`);
17
+ tools.tools.forEach(t => console.log(` - ${t.name}`));
18
+ } catch (error) {
19
+ console.error('Error:', error.message);
20
+ } finally {
21
+ await client.close();
22
+ }