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/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: Repository access token (required)
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().min(1, 'BITBUCKET_EMAIL is required'),
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: process.env.BITBUCKET_EMAIL || '',
40
- bitbucketApiToken: process.env.BITBUCKET_API_TOKEN || '',
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
-
@@ -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 in a repository.',
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: 'Filter by branch name (optional)' },
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 two commits or branches and see files changed.',
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 commits = await client.listCommits(args.repo_slug as string, {
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
- limit: validateLimit((args.limit as number) || 20),
119
+ cursor: args.cursor as string,
120
+ limit: validateLimit((args.limit as number) ?? 20),
89
121
  });
90
122
  return {
91
- commits: commits.map(c => ({
92
- hash: truncateHash(c.hash),
93
- message: c.message,
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 result = await client.compareCommits(
118
- args.repo_slug as string,
119
- args.base as string,
120
- args.head as string
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.slice(0, 50).map(f => ({
134
- path: f.new?.path || f.old?.path,
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, {
@@ -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';
@@ -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 in a repository.',
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: 'number', description: 'Maximum results (default: 50)', default: 50 },
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 tags = await client.listTags(args.repo_slug as string, {
54
- limit: validateLimit((args.limit as number) || 50),
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: tags.map(t => ({
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
+ });