mcp-server-bitbucket 0.12.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +9 -1
- package/dist/index.d.ts +16 -0
- package/dist/index.js +838 -139
- package/package.json +14 -12
- package/scripts/check-ca-bundles.cjs +27 -0
- package/smithery.yaml +10 -4
- package/src/auth.ts +330 -0
- package/src/client.ts +215 -123
- package/src/http-input.ts +36 -0
- package/src/index.ts +274 -13
- package/src/oauth-proxy.ts +298 -0
- package/src/prompts.ts +1 -1
- package/src/settings.ts +51 -10
- package/src/tools/commits.ts +65 -33
- package/src/tools/index.ts +0 -3
- package/src/tools/source.ts +23 -0
- package/src/tools/tags.ts +21 -6
- package/tests/auth.test.ts +277 -0
- package/tests/history.test.ts +284 -0
- package/tests/http-handler.test.ts +67 -0
- package/tests/http-input.test.ts +40 -0
- package/tests/network-resilience.test.ts +113 -0
- package/tests/oauth-proxy.test.ts +286 -0
- package/tests/settings.test.ts +110 -0
- package/tests/stdio.test.ts +20 -0
- package/tests/test-remote.mjs +22 -0
package/src/settings.ts
CHANGED
|
@@ -1,30 +1,65 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Settings management for Bitbucket MCP Server
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
4
|
* Configuration via environment variables:
|
|
5
5
|
* - BITBUCKET_WORKSPACE: Bitbucket workspace slug (required)
|
|
6
|
-
* - BITBUCKET_EMAIL: Account email for Basic Auth (required)
|
|
7
|
-
* - BITBUCKET_API_TOKEN:
|
|
6
|
+
* - BITBUCKET_EMAIL: Account email for Basic Auth (required for basic auth)
|
|
7
|
+
* - BITBUCKET_API_TOKEN: Atlassian API token / access token (required)
|
|
8
|
+
* - BITBUCKET_OAUTH_TOKEN: Access token used with `Authorization: Bearer` (optional)
|
|
9
|
+
* - BITBUCKET_AUTH_TYPE: Force auth mode - 'basic' or 'bearer' (optional, auto-detected)
|
|
8
10
|
* - API_TIMEOUT: Request timeout in seconds (default: 30, max: 300)
|
|
9
11
|
* - MAX_RETRIES: Max retry attempts for rate limiting (default: 3, max: 10)
|
|
10
12
|
* - OUTPUT_FORMAT: Output format - 'json' or 'toon' (default: json)
|
|
13
|
+
*
|
|
14
|
+
* Auth modes:
|
|
15
|
+
* - basic: Atlassian API tokens (ATATT...) authenticate as `email:token`.
|
|
16
|
+
* - bearer: Workspace/project/repository access tokens (ATCTT...) only work as
|
|
17
|
+
* `Authorization: Bearer <token>`; Basic auth returns 401 for them.
|
|
11
18
|
*/
|
|
12
19
|
|
|
13
20
|
import { z } from 'zod';
|
|
14
21
|
|
|
22
|
+
export type AuthType = 'basic' | 'bearer';
|
|
23
|
+
|
|
15
24
|
const settingsSchema = z.object({
|
|
16
25
|
bitbucketWorkspace: z.string().min(1, 'BITBUCKET_WORKSPACE is required'),
|
|
17
|
-
bitbucketEmail: z.string().
|
|
18
|
-
bitbucketApiToken: z.string().min(1, 'BITBUCKET_API_TOKEN is required'),
|
|
26
|
+
bitbucketEmail: z.string().default(''),
|
|
27
|
+
bitbucketApiToken: z.string().min(1, 'BITBUCKET_API_TOKEN (or BITBUCKET_OAUTH_TOKEN) is required'),
|
|
28
|
+
bitbucketAuthType: z.enum(['basic', 'bearer']),
|
|
19
29
|
apiTimeout: z.number().min(1).max(300).default(30),
|
|
20
30
|
maxRetries: z.number().min(0).max(10).default(3),
|
|
21
31
|
outputFormat: z.enum(['json', 'toon']).default('json'),
|
|
22
|
-
})
|
|
32
|
+
}).refine(
|
|
33
|
+
(s) => s.bitbucketAuthType !== 'basic' || s.bitbucketEmail.length > 0,
|
|
34
|
+
{ message: 'BITBUCKET_EMAIL is required when using basic auth', path: ['bitbucketEmail'] }
|
|
35
|
+
);
|
|
23
36
|
|
|
24
37
|
export type Settings = z.infer<typeof settingsSchema>;
|
|
25
38
|
|
|
26
39
|
let cachedSettings: Settings | null = null;
|
|
27
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Resolve which auth mode to use.
|
|
43
|
+
*
|
|
44
|
+
* Explicit BITBUCKET_AUTH_TYPE wins. Otherwise bearer is used when an
|
|
45
|
+
* OAuth/access token is configured or when no email is available (an access
|
|
46
|
+
* token has no account to pair with), and basic otherwise.
|
|
47
|
+
*/
|
|
48
|
+
function resolveAuthType(
|
|
49
|
+
explicit: string,
|
|
50
|
+
oauthToken: string,
|
|
51
|
+
email: string
|
|
52
|
+
): AuthType {
|
|
53
|
+
const normalized = explicit.trim().toLowerCase();
|
|
54
|
+
if (normalized === 'bearer' || normalized === 'basic') {
|
|
55
|
+
return normalized;
|
|
56
|
+
}
|
|
57
|
+
if (oauthToken.length > 0 || email.length === 0) {
|
|
58
|
+
return 'bearer';
|
|
59
|
+
}
|
|
60
|
+
return 'basic';
|
|
61
|
+
}
|
|
62
|
+
|
|
28
63
|
/**
|
|
29
64
|
* Load and validate settings from environment variables.
|
|
30
65
|
* Results are cached for subsequent calls.
|
|
@@ -34,17 +69,24 @@ export function getSettings(): Settings {
|
|
|
34
69
|
return cachedSettings;
|
|
35
70
|
}
|
|
36
71
|
|
|
72
|
+
const email = process.env.BITBUCKET_EMAIL || '';
|
|
73
|
+
const oauthToken = process.env.BITBUCKET_OAUTH_TOKEN || '';
|
|
74
|
+
const apiToken = process.env.BITBUCKET_API_TOKEN || '';
|
|
75
|
+
const authType = resolveAuthType(process.env.BITBUCKET_AUTH_TYPE || '', oauthToken, email);
|
|
76
|
+
|
|
37
77
|
const rawSettings = {
|
|
38
78
|
bitbucketWorkspace: process.env.BITBUCKET_WORKSPACE || '',
|
|
39
|
-
bitbucketEmail:
|
|
40
|
-
|
|
79
|
+
bitbucketEmail: email,
|
|
80
|
+
// Bearer mode prefers the dedicated OAuth/access token when both are set
|
|
81
|
+
bitbucketApiToken: authType === 'bearer' ? (oauthToken || apiToken) : apiToken,
|
|
82
|
+
bitbucketAuthType: authType,
|
|
41
83
|
apiTimeout: parseInt(process.env.API_TIMEOUT || '30', 10),
|
|
42
84
|
maxRetries: parseInt(process.env.MAX_RETRIES || '3', 10),
|
|
43
85
|
outputFormat: (process.env.OUTPUT_FORMAT || 'json') as 'json' | 'toon',
|
|
44
86
|
};
|
|
45
87
|
|
|
46
88
|
const result = settingsSchema.safeParse(rawSettings);
|
|
47
|
-
|
|
89
|
+
|
|
48
90
|
if (!result.success) {
|
|
49
91
|
const errors = result.error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ');
|
|
50
92
|
throw new Error(`Configuration error: ${errors}`);
|
|
@@ -60,4 +102,3 @@ export function getSettings(): Settings {
|
|
|
60
102
|
export function resetSettings(): void {
|
|
61
103
|
cachedSettings = null;
|
|
62
104
|
}
|
|
63
|
-
|
package/src/tools/commits.ts
CHANGED
|
@@ -10,12 +10,15 @@ import { CommitStatusState } from '../types.js';
|
|
|
10
10
|
export const definitions: Tool[] = [
|
|
11
11
|
{
|
|
12
12
|
name: 'list_commits',
|
|
13
|
-
description: 'List commits
|
|
13
|
+
description: 'List a page of commits. Use include/exclude for commits between revisions (include=head, exclude=base); continue with next_cursor until has_more is false.',
|
|
14
14
|
inputSchema: {
|
|
15
15
|
type: 'object',
|
|
16
16
|
properties: {
|
|
17
17
|
repo_slug: { type: 'string', description: 'Repository slug' },
|
|
18
|
-
branch: { type: 'string', description: '
|
|
18
|
+
branch: { type: 'string', description: 'Branch, tag or SHA to include (alias for include)' },
|
|
19
|
+
include: { type: 'array', items: { type: 'string' }, description: 'Include commits reachable from these branches, tags or SHAs' },
|
|
20
|
+
exclude: { type: 'array', items: { type: 'string' }, description: 'Exclude commits reachable from these revisions' },
|
|
21
|
+
cursor: { type: 'string', description: 'Opaque next_cursor from this tool; retains its page size and filters' },
|
|
19
22
|
path: { type: 'string', description: 'Filter by file path - only commits that modified this path (optional)' },
|
|
20
23
|
limit: { type: 'number', description: 'Maximum results (default: 20)', default: 20 },
|
|
21
24
|
},
|
|
@@ -36,17 +39,43 @@ export const definitions: Tool[] = [
|
|
|
36
39
|
},
|
|
37
40
|
{
|
|
38
41
|
name: 'compare_commits',
|
|
39
|
-
description: 'Compare
|
|
42
|
+
description: 'Compare base to head (branches, tags or SHAs). Returns a page of changed files and line counts; follow next_cursor for all files.',
|
|
40
43
|
inputSchema: {
|
|
41
44
|
type: 'object',
|
|
42
45
|
properties: {
|
|
43
46
|
repo_slug: { type: 'string', description: 'Repository slug' },
|
|
44
47
|
base: { type: 'string', description: 'Base commit hash or branch name' },
|
|
45
|
-
head: { type: 'string', description: 'Head commit hash or branch name' },
|
|
48
|
+
head: { type: 'string', description: 'Head commit hash, tag or branch name' },
|
|
49
|
+
path: { type: 'string', description: 'Optional file path filter' },
|
|
50
|
+
limit: { type: 'integer', minimum: 1, maximum: 100, default: 50 },
|
|
51
|
+
cursor: { type: 'string', description: 'Opaque next_cursor; retains page size and filters' },
|
|
46
52
|
},
|
|
47
53
|
required: ['repo_slug', 'base', 'head'],
|
|
48
54
|
},
|
|
49
55
|
},
|
|
56
|
+
{
|
|
57
|
+
name: 'get_commit_diff',
|
|
58
|
+
description: 'Get the raw Git-style diff from base to head. Continue large diffs with next_offset; pin revisions to commit hashes while paging. API-side diff limits still apply.',
|
|
59
|
+
inputSchema: {
|
|
60
|
+
type: 'object',
|
|
61
|
+
properties: {
|
|
62
|
+
repo_slug: { type: 'string' }, base: { type: 'string' }, head: { type: 'string' },
|
|
63
|
+
path: { type: 'string', description: 'Optional file path filter' },
|
|
64
|
+
offset: { type: 'integer', minimum: 0, default: 0 },
|
|
65
|
+
max_chars: { type: 'integer', minimum: 1, maximum: 100000, default: 20000 },
|
|
66
|
+
},
|
|
67
|
+
required: ['repo_slug', 'base', 'head'],
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: 'get_merge_base',
|
|
72
|
+
description: 'Get a best common ancestor of two branches, tags or commit hashes. Bitbucket returns one ancestor when multiple best ancestors exist.',
|
|
73
|
+
inputSchema: {
|
|
74
|
+
type: 'object',
|
|
75
|
+
properties: { repo_slug: { type: 'string' }, base: { type: 'string' }, head: { type: 'string' } },
|
|
76
|
+
required: ['repo_slug', 'base', 'head'],
|
|
77
|
+
},
|
|
78
|
+
},
|
|
50
79
|
{
|
|
51
80
|
name: 'get_commit_statuses',
|
|
52
81
|
description: 'Get build/CI statuses for a commit.',
|
|
@@ -82,18 +111,18 @@ export const definitions: Tool[] = [
|
|
|
82
111
|
export const handlers: Record<string, (args: Record<string, unknown>) => Promise<Record<string, unknown>>> = {
|
|
83
112
|
list_commits: async (args) => {
|
|
84
113
|
const client = getClient();
|
|
85
|
-
const
|
|
114
|
+
const page = await client.listCommitsPage(args.repo_slug as string, {
|
|
86
115
|
branch: args.branch as string,
|
|
116
|
+
include: args.include as string[] | undefined,
|
|
117
|
+
exclude: args.exclude as string[] | undefined,
|
|
87
118
|
path: args.path as string,
|
|
88
|
-
|
|
119
|
+
cursor: args.cursor as string,
|
|
120
|
+
limit: validateLimit((args.limit as number) ?? 20),
|
|
89
121
|
});
|
|
90
122
|
return {
|
|
91
|
-
commits:
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
author: c.author?.raw,
|
|
95
|
-
date: c.date,
|
|
96
|
-
})),
|
|
123
|
+
commits: page.values.map(c => ({ hash: truncateHash(c.hash), message: c.message, author: c.author?.raw, date: c.date })),
|
|
124
|
+
has_more: page.has_more,
|
|
125
|
+
next_cursor: page.next_cursor,
|
|
97
126
|
};
|
|
98
127
|
},
|
|
99
128
|
|
|
@@ -114,31 +143,34 @@ export const handlers: Record<string, (args: Record<string, unknown>) => Promise
|
|
|
114
143
|
|
|
115
144
|
compare_commits: async (args) => {
|
|
116
145
|
const client = getClient();
|
|
117
|
-
const
|
|
118
|
-
args.
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
);
|
|
122
|
-
if (!result) {
|
|
123
|
-
return { error: `Could not compare ${args.base}..${args.head}` };
|
|
124
|
-
}
|
|
125
|
-
const files = (result.values as Array<{
|
|
126
|
-
new?: { path?: string };
|
|
127
|
-
old?: { path?: string };
|
|
128
|
-
status?: string;
|
|
129
|
-
lines_added?: number;
|
|
130
|
-
lines_removed?: number;
|
|
131
|
-
}>) || [];
|
|
146
|
+
const page = await client.compareCommitsPage(args.repo_slug as string, args.base as string, args.head as string, {
|
|
147
|
+
path: args.path as string, limit: validateLimit((args.limit as number) ?? 50), cursor: args.cursor as string,
|
|
148
|
+
});
|
|
149
|
+
const files = page.values as Array<{ new?: { path?: string }; old?: { path?: string }; status?: string; lines_added?: number; lines_removed?: number }>;
|
|
132
150
|
return {
|
|
133
|
-
files: files.
|
|
134
|
-
|
|
135
|
-
status: f.status,
|
|
136
|
-
'+': f.lines_added || 0,
|
|
137
|
-
'-': f.lines_removed || 0,
|
|
138
|
-
})),
|
|
151
|
+
files: files.map(f => ({ path: f.new?.path || f.old?.path, status: f.status, '+': f.lines_added || 0, '-': f.lines_removed || 0 })),
|
|
152
|
+
has_more: page.has_more, next_cursor: page.next_cursor,
|
|
139
153
|
};
|
|
140
154
|
},
|
|
141
155
|
|
|
156
|
+
get_commit_diff: async (args) => {
|
|
157
|
+
const offset = args.offset === undefined ? 0 : Number(args.offset);
|
|
158
|
+
const maxChars = args.max_chars === undefined ? 20000 : Number(args.max_chars);
|
|
159
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(maxChars) || maxChars < 1 || maxChars > 100000) {
|
|
160
|
+
return { success: false, error: 'offset must be a nonnegative integer; max_chars must be an integer between 1 and 100000' };
|
|
161
|
+
}
|
|
162
|
+
const diff = await getClient().getCommitDiff(args.repo_slug as string, args.base as string, args.head as string, { path: args.path as string });
|
|
163
|
+
if (diff === null) return notFoundResponse('Diff', `${args.base}..${args.head}`);
|
|
164
|
+
const end = Math.min(offset + maxChars, diff.length);
|
|
165
|
+
return { diff: diff.slice(offset, end), offset, next_offset: end < diff.length ? end : null, truncated: end < diff.length, total_chars: diff.length };
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
get_merge_base: async (args) => {
|
|
169
|
+
const result = await getClient().getMergeBase(args.repo_slug as string, args.base as string, args.head as string);
|
|
170
|
+
return result ? { hash: result.hash, message: result.message, author: result.author?.raw, date: result.date, parents: result.parents?.map(p => truncateHash(p.hash)) }
|
|
171
|
+
: notFoundResponse('Common ancestor', `${args.base}..${args.head}`);
|
|
172
|
+
},
|
|
173
|
+
|
|
142
174
|
get_commit_statuses: async (args) => {
|
|
143
175
|
const client = getClient();
|
|
144
176
|
const statuses = await client.getCommitStatuses(args.repo_slug as string, args.commit as string, {
|
package/src/tools/index.ts
CHANGED
|
@@ -3,9 +3,6 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
6
|
-
import { getClient } from '../client.js';
|
|
7
|
-
import { validateLimit, notFoundResponse, truncateHash, sanitizeSearchTerm } from '../utils.js';
|
|
8
|
-
import { PRState, MergeStrategy, CommitStatusState } from '../types.js';
|
|
9
6
|
|
|
10
7
|
// Import tool handlers from separate files
|
|
11
8
|
import * as repositoryTools from './repositories.js';
|
package/src/tools/source.ts
CHANGED
|
@@ -20,6 +20,19 @@ export const definitions: Tool[] = [
|
|
|
20
20
|
required: ['repo_slug', 'path'],
|
|
21
21
|
},
|
|
22
22
|
},
|
|
23
|
+
{
|
|
24
|
+
name: 'get_file_history',
|
|
25
|
+
description: 'List file-history paths and full commit hashes at a branch, tag or SHA. Use get_commit for author, message and date. Follow next_cursor for older changes.',
|
|
26
|
+
inputSchema: {
|
|
27
|
+
type: 'object',
|
|
28
|
+
properties: {
|
|
29
|
+
repo_slug: { type: 'string' }, path: { type: 'string' }, ref: { type: 'string', default: 'main' },
|
|
30
|
+
limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
|
|
31
|
+
cursor: { type: 'string', description: 'Opaque next_cursor; retains page size and filters' },
|
|
32
|
+
},
|
|
33
|
+
required: ['repo_slug', 'path'],
|
|
34
|
+
},
|
|
35
|
+
},
|
|
23
36
|
{
|
|
24
37
|
name: 'list_directory',
|
|
25
38
|
description: 'List contents of a directory in a repository. Browse repository structure without cloning.',
|
|
@@ -54,6 +67,16 @@ export const handlers: Record<string, (args: Record<string, unknown>) => Promise
|
|
|
54
67
|
};
|
|
55
68
|
},
|
|
56
69
|
|
|
70
|
+
get_file_history: async (args) => {
|
|
71
|
+
const page = await getClient().getFileHistory(args.repo_slug as string, args.path as string, (args.ref as string) || 'main', {
|
|
72
|
+
limit: validateLimit((args.limit as number) ?? 20), cursor: args.cursor as string,
|
|
73
|
+
});
|
|
74
|
+
return {
|
|
75
|
+
history: page.values.map(entry => ({ path: entry.path, commit: { hash: entry.commit.hash } })),
|
|
76
|
+
has_more: page.has_more, next_cursor: page.next_cursor,
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
|
|
57
80
|
list_directory: async (args) => {
|
|
58
81
|
const client = getClient();
|
|
59
82
|
const ref = (args.ref as string) || 'main';
|
package/src/tools/tags.ts
CHANGED
|
@@ -4,21 +4,29 @@
|
|
|
4
4
|
|
|
5
5
|
import { Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
6
6
|
import { getClient } from '../client.js';
|
|
7
|
-
import { validateLimit, truncateHash } from '../utils.js';
|
|
7
|
+
import { validateLimit, truncateHash, notFoundResponse } from '../utils.js';
|
|
8
8
|
|
|
9
9
|
export const definitions: Tool[] = [
|
|
10
10
|
{
|
|
11
11
|
name: 'list_tags',
|
|
12
|
-
description: 'List tags
|
|
12
|
+
description: 'List a page of tags, optionally filtered with Bitbucket query syntax and sorted (name uses natural version order). Follow next_cursor for all tags.',
|
|
13
13
|
inputSchema: {
|
|
14
14
|
type: 'object',
|
|
15
15
|
properties: {
|
|
16
16
|
repo_slug: { type: 'string', description: 'Repository slug' },
|
|
17
|
-
limit: { type: '
|
|
17
|
+
limit: { type: 'integer', minimum: 1, maximum: 100, default: 50 },
|
|
18
|
+
query: { type: 'string', description: 'Bitbucket filter, e.g. name ~ \"v2\" or target.hash = \"full-sha\"' },
|
|
19
|
+
sort: { type: 'string', description: 'Sort field, e.g. name, -name, -target.date', default: '-target.date' },
|
|
20
|
+
cursor: { type: 'string', description: 'Opaque next_cursor; retains page size and filters' },
|
|
18
21
|
},
|
|
19
22
|
required: ['repo_slug'],
|
|
20
23
|
},
|
|
21
24
|
},
|
|
25
|
+
{
|
|
26
|
+
name: 'get_tag',
|
|
27
|
+
description: 'Get a specific tag and its target commit without scanning tag pages.',
|
|
28
|
+
inputSchema: { type: 'object', properties: { repo_slug: { type: 'string' }, tag_name: { type: 'string' } }, required: ['repo_slug', 'tag_name'] },
|
|
29
|
+
},
|
|
22
30
|
{
|
|
23
31
|
name: 'create_tag',
|
|
24
32
|
description: 'Create a new tag in a repository.',
|
|
@@ -50,20 +58,27 @@ export const definitions: Tool[] = [
|
|
|
50
58
|
export const handlers: Record<string, (args: Record<string, unknown>) => Promise<Record<string, unknown>>> = {
|
|
51
59
|
list_tags: async (args) => {
|
|
52
60
|
const client = getClient();
|
|
53
|
-
const
|
|
54
|
-
limit: validateLimit((args.limit as number)
|
|
61
|
+
const page = await client.listTagsPage(args.repo_slug as string, {
|
|
62
|
+
limit: validateLimit((args.limit as number) ?? 50),
|
|
63
|
+
query: args.query as string, sort: args.sort as string, cursor: args.cursor as string,
|
|
55
64
|
});
|
|
56
65
|
return {
|
|
57
|
-
tags:
|
|
66
|
+
tags: page.values.map(t => ({
|
|
58
67
|
name: t.name,
|
|
59
68
|
target: truncateHash(t.target?.hash),
|
|
60
69
|
message: t.message,
|
|
61
70
|
date: t.target?.date,
|
|
62
71
|
tagger: t.tagger?.raw,
|
|
63
72
|
})),
|
|
73
|
+
has_more: page.has_more, next_cursor: page.next_cursor,
|
|
64
74
|
};
|
|
65
75
|
},
|
|
66
76
|
|
|
77
|
+
get_tag: async (args) => {
|
|
78
|
+
const tag = await getClient().getTag(args.repo_slug as string, args.tag_name as string);
|
|
79
|
+
return tag ? { name: tag.name, target: tag.target?.hash, message: tag.message, date: tag.date, tagger: tag.tagger?.raw } : notFoundResponse('Tag', args.tag_name as string);
|
|
80
|
+
},
|
|
81
|
+
|
|
67
82
|
create_tag: async (args) => {
|
|
68
83
|
const client = getClient();
|
|
69
84
|
const result = await client.createTag(
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the OAuth bearer protection of the HTTP transport.
|
|
3
|
+
*
|
|
4
|
+
* Tokens are signed with a locally generated key pair and verified against that
|
|
5
|
+
* same key, so nothing here touches the network or a real authorization server.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
|
|
9
|
+
import { SignJWT, exportJWK, generateKeyPair, createLocalJWKSet, type JWTVerifyGetKey } from 'jose';
|
|
10
|
+
import {
|
|
11
|
+
authorizationServerMetadata,
|
|
12
|
+
defaultJwksUri,
|
|
13
|
+
getAuthConfig,
|
|
14
|
+
METADATA_PATHS,
|
|
15
|
+
metadataUrl,
|
|
16
|
+
protectedResourceMetadata,
|
|
17
|
+
resetAuthorizationServerMetadataCache,
|
|
18
|
+
verifyBearer,
|
|
19
|
+
wwwAuthenticate,
|
|
20
|
+
type AuthConfig,
|
|
21
|
+
} from '../src/auth.js';
|
|
22
|
+
|
|
23
|
+
const ISSUER = 'https://login.microsoftonline.com/2f340cb1-3fa1-4e6e-b876-98a0f3732499/v2.0';
|
|
24
|
+
const AUDIENCE = 'api://bitbucket-mcp';
|
|
25
|
+
const RESOURCE = 'https://bitbucket-mcp-server.example.com';
|
|
26
|
+
|
|
27
|
+
let privateKey: CryptoKey;
|
|
28
|
+
let jwks: JWTVerifyGetKey;
|
|
29
|
+
|
|
30
|
+
beforeAll(async () => {
|
|
31
|
+
const pair = await generateKeyPair('RS256', { extractable: true });
|
|
32
|
+
privateKey = pair.privateKey;
|
|
33
|
+
const publicJwk = await exportJWK(pair.publicKey);
|
|
34
|
+
publicJwk.kid = 'test-key';
|
|
35
|
+
publicJwk.alg = 'RS256';
|
|
36
|
+
jwks = createLocalJWKSet({ keys: [publicJwk] });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
function config(overrides: Partial<AuthConfig> = {}): AuthConfig {
|
|
40
|
+
return {
|
|
41
|
+
issuer: ISSUER,
|
|
42
|
+
audience: AUDIENCE,
|
|
43
|
+
resourceUrl: `${RESOURCE}/mcp`,
|
|
44
|
+
resourceIdentifier: `${RESOURCE}/mcp`,
|
|
45
|
+
proxyAuthorizationServerMetadata: true,
|
|
46
|
+
jwks,
|
|
47
|
+
...overrides,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function token(claims: Record<string, unknown> = {}, options: { expired?: boolean } = {}) {
|
|
52
|
+
const now = Math.floor(Date.now() / 1000);
|
|
53
|
+
return new SignJWT({ ...claims })
|
|
54
|
+
.setProtectedHeader({ alg: 'RS256', kid: 'test-key' })
|
|
55
|
+
.setIssuer((claims.iss as string) ?? ISSUER)
|
|
56
|
+
.setAudience((claims.aud as string) ?? AUDIENCE)
|
|
57
|
+
.setSubject((claims.sub as string) ?? 'user-123')
|
|
58
|
+
.setIssuedAt(options.expired ? now - 7200 : now)
|
|
59
|
+
.setExpirationTime(options.expired ? now - 3600 : now + 3600)
|
|
60
|
+
.sign(privateKey);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('configuration', () => {
|
|
64
|
+
it('is disabled unless both issuer and audience are set', () => {
|
|
65
|
+
expect(getAuthConfig({}, jwks)).toBeNull();
|
|
66
|
+
expect(getAuthConfig({ MCP_OAUTH_ISSUER: ISSUER }, jwks)).toBeNull();
|
|
67
|
+
expect(getAuthConfig({ MCP_OAUTH_AUDIENCE: AUDIENCE }, jwks)).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('is enabled when both are set', () => {
|
|
71
|
+
const cfg = getAuthConfig(
|
|
72
|
+
{ MCP_OAUTH_ISSUER: ISSUER, MCP_OAUTH_AUDIENCE: AUDIENCE, MCP_PUBLIC_URL: `${RESOURCE}/` },
|
|
73
|
+
jwks
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
expect(cfg).not.toBeNull();
|
|
77
|
+
expect(cfg!.issuer).toBe(ISSUER);
|
|
78
|
+
// trailing slash trimmed, and the resource is the MCP endpoint itself
|
|
79
|
+
expect(cfg!.resourceUrl).toBe(`${RESOURCE}/mcp`);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('derives the Entra keys endpoint from the issuer', () => {
|
|
83
|
+
expect(defaultJwksUri(ISSUER)).toBe(
|
|
84
|
+
'https://login.microsoftonline.com/2f340cb1-3fa1-4e6e-b876-98a0f3732499/discovery/v2.0/keys'
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('metadata', () => {
|
|
90
|
+
it('advertises the issuer as the authorization server', () => {
|
|
91
|
+
const meta = protectedResourceMetadata(config({ requiredScopes: ['mcp.access'] }));
|
|
92
|
+
|
|
93
|
+
expect(meta).toMatchObject({
|
|
94
|
+
// we serve the RFC 8414 document ourselves, so we are the advertised AS
|
|
95
|
+
resource: `${RESOURCE}/mcp`,
|
|
96
|
+
authorization_servers: [RESOURCE],
|
|
97
|
+
scopes_supported: ['mcp.access'],
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('advertises an overridden resource identifier when the URL cannot be used', () => {
|
|
102
|
+
// Entra rejects the canonical URL with AADSTS9010010 unless it matches the
|
|
103
|
+
// scopes, so against Entra the resource is the API's Application ID URI
|
|
104
|
+
const meta = protectedResourceMetadata(config({ resourceIdentifier: 'api://bitbucket-mcp' }));
|
|
105
|
+
|
|
106
|
+
expect(meta).toMatchObject({ resource: 'api://bitbucket-mcp' });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('points at the issuer directly when not proxying its metadata', () => {
|
|
110
|
+
const meta = protectedResourceMetadata(config({ proxyAuthorizationServerMetadata: false }));
|
|
111
|
+
|
|
112
|
+
expect(meta).toMatchObject({ authorization_servers: [ISSUER] });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('advertises the scopes clients must request when they differ from the validated ones', () => {
|
|
116
|
+
// Entra validates `scp: mcp.access` but the client has to ask for the full URI
|
|
117
|
+
const meta = protectedResourceMetadata(
|
|
118
|
+
config({ requiredScopes: ['mcp.access'], advertisedScopes: ['api://bitbucket-mcp/mcp.access'] })
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
expect(meta).toMatchObject({ scopes_supported: ['api://bitbucket-mcp/mcp.access'] });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('accepts a token carrying any one of several accepted scopes', async () => {
|
|
125
|
+
const cfg = config({ requiredScopes: ['mcp.access', 'mcp.invoke'] });
|
|
126
|
+
|
|
127
|
+
expect((await verifyBearer(`Bearer ${await token({ scp: 'mcp.access' })}`, cfg)).ok).toBe(true);
|
|
128
|
+
expect((await verifyBearer(`Bearer ${await token({ roles: ['mcp.invoke'] })}`, cfg)).ok).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('points the WWW-Authenticate header at the metadata document', () => {
|
|
132
|
+
expect(wwwAuthenticate(config())).toContain(
|
|
133
|
+
`resource_metadata="${RESOURCE}/.well-known/oauth-protected-resource/mcp"`
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('derives the metadata URL from the resource, without duplicating /mcp', () => {
|
|
138
|
+
expect(metadataUrl(config())).toBe(`${RESOURCE}/.well-known/oauth-protected-resource/mcp`);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('serves the metadata at both the path-aware and the bare well-known location', () => {
|
|
142
|
+
// RFC 9728 derives the path from the resource; some clients only try the bare one
|
|
143
|
+
expect(METADATA_PATHS).toEqual([
|
|
144
|
+
'/.well-known/oauth-protected-resource/mcp',
|
|
145
|
+
'/.well-known/oauth-protected-resource',
|
|
146
|
+
]);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('token verification', () => {
|
|
151
|
+
it('accepts a valid token', async () => {
|
|
152
|
+
const result = await verifyBearer(`Bearer ${await token({ preferred_username: 'javier@simplekyc.com' })}`, config());
|
|
153
|
+
|
|
154
|
+
expect(result.ok).toBe(true);
|
|
155
|
+
if (result.ok) {
|
|
156
|
+
expect(result.subject).toBe('user-123');
|
|
157
|
+
expect(result.caller).toBe('javier@simplekyc.com');
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('rejects a missing header with 401', async () => {
|
|
162
|
+
const result = await verifyBearer(undefined, config());
|
|
163
|
+
|
|
164
|
+
expect(result).toMatchObject({ ok: false, status: 401, error: 'invalid_token' });
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('rejects a non-Bearer scheme', async () => {
|
|
168
|
+
const result = await verifyBearer('Basic dXNlcjpwYXNz', config());
|
|
169
|
+
|
|
170
|
+
expect(result).toMatchObject({ ok: false, status: 401 });
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('rejects a token for another audience', async () => {
|
|
174
|
+
const result = await verifyBearer(`Bearer ${await token({ aud: 'api://something-else' })}`, config());
|
|
175
|
+
|
|
176
|
+
expect(result).toMatchObject({ ok: false, status: 401, error: 'invalid_token' });
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('rejects a token from another issuer', async () => {
|
|
180
|
+
const result = await verifyBearer(`Bearer ${await token({ iss: 'https://evil.example.com' })}`, config());
|
|
181
|
+
|
|
182
|
+
expect(result).toMatchObject({ ok: false, status: 401 });
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('rejects an expired token', async () => {
|
|
186
|
+
const result = await verifyBearer(`Bearer ${await token({}, { expired: true })}`, config());
|
|
187
|
+
|
|
188
|
+
expect(result).toMatchObject({ ok: false, status: 401 });
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('rejects a tampered signature', async () => {
|
|
192
|
+
const valid = await token();
|
|
193
|
+
const tampered = `${valid.slice(0, -4)}AAAA`;
|
|
194
|
+
|
|
195
|
+
const result = await verifyBearer(`Bearer ${tampered}`, config());
|
|
196
|
+
|
|
197
|
+
expect(result).toMatchObject({ ok: false, status: 401 });
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe('scopes', () => {
|
|
202
|
+
it('accepts a delegated token carrying the required scope in scp', async () => {
|
|
203
|
+
const result = await verifyBearer(
|
|
204
|
+
`Bearer ${await token({ scp: 'mcp.access other.scope' })}`,
|
|
205
|
+
config({ requiredScopes: ['mcp.access'] })
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
expect(result.ok).toBe(true);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('accepts an application token carrying it in roles', async () => {
|
|
212
|
+
const result = await verifyBearer(
|
|
213
|
+
`Bearer ${await token({ roles: ['mcp.access'] })}`,
|
|
214
|
+
config({ requiredScopes: ['mcp.access'] })
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
expect(result.ok).toBe(true);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('answers 403 insufficient_scope when the scope is missing', async () => {
|
|
221
|
+
const result = await verifyBearer(
|
|
222
|
+
`Bearer ${await token({ scp: 'other.scope' })}`,
|
|
223
|
+
config({ requiredScopes: ['mcp.access'] })
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
expect(result).toMatchObject({ ok: false, status: 403, error: 'insufficient_scope' });
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
describe('authorization server metadata', () => {
|
|
232
|
+
// Entra serves no RFC 8414 document, which is where the connector flow died:
|
|
233
|
+
// the client read our resource metadata and then had nowhere to go.
|
|
234
|
+
const openIdConfiguration = {
|
|
235
|
+
issuer: ISSUER,
|
|
236
|
+
authorization_endpoint: 'https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize',
|
|
237
|
+
token_endpoint: 'https://login.microsoftonline.com/tenant/oauth2/v2.0/token',
|
|
238
|
+
response_types_supported: ['code'],
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
beforeEach(() => resetAuthorizationServerMetadataCache());
|
|
242
|
+
|
|
243
|
+
it('republishes the issuer configuration, asserting PKCE support', async () => {
|
|
244
|
+
const fetchImpl = async () =>
|
|
245
|
+
new Response(JSON.stringify(openIdConfiguration), { status: 200 }) as Response;
|
|
246
|
+
|
|
247
|
+
const meta = await authorizationServerMetadata(config(), fetchImpl as typeof fetch);
|
|
248
|
+
|
|
249
|
+
expect(meta).toMatchObject({
|
|
250
|
+
issuer: ISSUER,
|
|
251
|
+
authorization_endpoint: openIdConfiguration.authorization_endpoint,
|
|
252
|
+
token_endpoint: openIdConfiguration.token_endpoint,
|
|
253
|
+
code_challenge_methods_supported: ['S256'],
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('reads the issuer once and then serves from cache', async () => {
|
|
258
|
+
let calls = 0;
|
|
259
|
+
const fetchImpl = async () => {
|
|
260
|
+
calls += 1;
|
|
261
|
+
return new Response(JSON.stringify(openIdConfiguration), { status: 200 }) as Response;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
await authorizationServerMetadata(config(), fetchImpl as typeof fetch);
|
|
265
|
+
await authorizationServerMetadata(config(), fetchImpl as typeof fetch);
|
|
266
|
+
|
|
267
|
+
expect(calls).toBe(1);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('surfaces an unreachable issuer instead of serving a broken document', async () => {
|
|
271
|
+
const fetchImpl = async () => new Response('nope', { status: 500 }) as Response;
|
|
272
|
+
|
|
273
|
+
await expect(authorizationServerMetadata(config(), fetchImpl as typeof fetch)).rejects.toThrow(
|
|
274
|
+
/HTTP 500/
|
|
275
|
+
);
|
|
276
|
+
});
|
|
277
|
+
});
|