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.
package/src/index.ts CHANGED
@@ -1,13 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * Bitbucket MCP Server - TypeScript Implementation
4
- *
4
+ *
5
5
  * Provides tools for interacting with Bitbucket repositories,
6
6
  * pull requests, pipelines, branches, commits, deployments, and webhooks.
7
+ *
8
+ * Supports two transport modes:
9
+ * - stdio (default): For local MCP clients like Claude Desktop
10
+ * - http: For remote MCP clients via Streamable HTTP (set MCP_TRANSPORT=http)
7
11
  */
8
12
 
9
13
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
10
14
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
15
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
11
16
  import {
12
17
  CallToolRequestSchema,
13
18
  ListToolsRequestSchema,
@@ -16,13 +21,37 @@ import {
16
21
  ListPromptsRequestSchema,
17
22
  GetPromptRequestSchema,
18
23
  } from '@modelcontextprotocol/sdk/types.js';
24
+ import { createServer as createHttpServer, type RequestListener } from 'node:http';
25
+ import { pathToFileURL } from 'node:url';
26
+ import { realpathSync } from 'node:fs';
27
+ import { HttpInputError, parseMcpBody, readHttpBody } from './http-input.js';
28
+ import {
29
+ AS_METADATA_PATH,
30
+ getAuthConfig,
31
+ METADATA_PATHS,
32
+ protectedResourceMetadata,
33
+ verifyBearer,
34
+ wwwAuthenticate,
35
+ } from './auth.js';
36
+ import {
37
+ AUTHORIZE_PATH,
38
+ buildAuthorizeRedirect,
39
+ forwardTokenRequest,
40
+ getProxyConfig,
41
+ proxyAuthorizationServerMetadata,
42
+ redactUrlForLog,
43
+ REGISTER_PATH,
44
+ registerClient,
45
+ resolveUpstreamEndpoints,
46
+ TOKEN_PATH,
47
+ } from './oauth-proxy.js';
19
48
 
20
49
  import { getSettings } from './settings.js';
21
50
  import { toolDefinitions, handleToolCall } from './tools/index.js';
22
51
  import { resourceDefinitions, handleResourceRead } from './resources.js';
23
52
  import { promptDefinitions, handlePromptGet } from './prompts.js';
24
53
 
25
- const VERSION = '0.10.0';
54
+ const VERSION = '0.19.0';
26
55
 
27
56
  function createServer(): Server {
28
57
  const server = new Server(
@@ -125,6 +154,235 @@ function createServer(): Server {
125
154
  return server;
126
155
  }
127
156
 
157
+ async function startStdio(): Promise<void> {
158
+ const server = createServer();
159
+ const transport = new StdioServerTransport();
160
+ await server.connect(transport);
161
+ console.error(`Bitbucket MCP Server v${VERSION} started (stdio)`);
162
+ }
163
+
164
+ export function createHttpHandler(): RequestListener {
165
+ const sessions = new Map<string, { server: Server; transport: StreamableHTTPServerTransport }>();
166
+
167
+ // OAuth protection for /mcp. Null when unconfigured, in which case the
168
+ // endpoint stays open exactly as before (see src/auth.ts).
169
+ const auth = getAuthConfig();
170
+ // We stand in as the authorization server, translating to the real one (see
171
+ // src/oauth-proxy.ts for why going direct cannot work with Entra).
172
+ const proxy = auth ? getProxyConfig(auth) : null;
173
+
174
+ return async (req, res) => {
175
+ // CORS headers for remote MCP clients
176
+ res.setHeader('Access-Control-Allow-Origin', '*');
177
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, DELETE');
178
+ res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type, mcp-session-id');
179
+ res.setHeader('Access-Control-Expose-Headers', 'mcp-session-id');
180
+
181
+ if (req.method === 'OPTIONS') {
182
+ res.writeHead(204);
183
+ res.end();
184
+ return;
185
+ }
186
+
187
+ // One line per request. With a single shared Bitbucket credential this is
188
+ // the only record of who used the server, and it is what makes an OAuth
189
+ // problem diagnosable from the outside.
190
+ const clientIp =
191
+ (req.headers['x-forwarded-for'] as string | undefined)?.split(',')[0]?.trim() ??
192
+ req.socket.remoteAddress ??
193
+ 'unknown';
194
+ res.on('finish', () => {
195
+ console.error(
196
+ `${req.method} ${redactUrlForLog(req.url ?? '')} -> ${res.statusCode} from ${clientIp}${
197
+ callerLabel ? ` (${callerLabel})` : ''
198
+ }`
199
+ );
200
+ });
201
+ let callerLabel = '';
202
+
203
+ // Health check — deliberately left open so platform probes keep working
204
+ if (req.method === 'GET' && req.url === '/health') {
205
+ res.writeHead(200, { 'Content-Type': 'application/json' });
206
+ res.end(JSON.stringify({ status: 'ok', version: VERSION }));
207
+ return;
208
+ }
209
+
210
+ // Tells clients which authorization server issues tokens for this resource
211
+ if (auth && req.method === 'GET' && METADATA_PATHS.includes(req.url || '')) {
212
+ res.writeHead(200, { 'Content-Type': 'application/json' });
213
+ res.end(JSON.stringify(protectedResourceMetadata(auth)));
214
+ return;
215
+ }
216
+
217
+ // We are the authorization server as far as clients are concerned.
218
+ if (auth && proxy && req.method === 'GET' && req.url === AS_METADATA_PATH) {
219
+ res.writeHead(200, { 'Content-Type': 'application/json' });
220
+ res.end(JSON.stringify(proxyAuthorizationServerMetadata(auth, proxy)));
221
+ return;
222
+ }
223
+
224
+ // Authorization request: hand the user to the real authorization server,
225
+ // rewriting only what it will not accept.
226
+ if (auth && proxy && req.method === 'GET' && req.url?.startsWith(AUTHORIZE_PATH)) {
227
+ try {
228
+ const { authorize } = await resolveUpstreamEndpoints(auth);
229
+ const requestUrl = new URL(req.url, proxy.origin);
230
+ const result = buildAuthorizeRedirect(requestUrl, proxy, authorize);
231
+
232
+ if (result.status === 302) {
233
+ res.writeHead(302, { Location: result.location });
234
+ res.end();
235
+ } else {
236
+ res.writeHead(400, { 'Content-Type': 'application/json' });
237
+ res.end(JSON.stringify({ error: result.error, error_description: result.description }));
238
+ }
239
+ } catch (error) {
240
+ console.error(`Authorization request could not be forwarded: ${error}`);
241
+ res.writeHead(502, { 'Content-Type': 'application/json' });
242
+ res.end(JSON.stringify({ error: 'authorization_server_unavailable' }));
243
+ }
244
+ return;
245
+ }
246
+
247
+ // Dynamic client registration: MCP clients refuse an authorization server
248
+ // without it, so we hand back the client that already exists upstream.
249
+ if (auth && proxy && req.method === 'POST' && req.url === REGISTER_PATH) {
250
+ let parsed: unknown = {};
251
+ try {
252
+ parsed = JSON.parse((await readHttpBody(req.iterator({ destroyOnReturn: false }))).toString('utf8') || '{}');
253
+ } catch (error) {
254
+ const status = error instanceof HttpInputError ? error.status : 400;
255
+ res.writeHead(status, { 'Content-Type': 'application/json', Connection: 'close' });
256
+ res.end(JSON.stringify({ error: 'invalid_request', error_description:
257
+ error instanceof HttpInputError ? error.message : 'Body must be JSON' }));
258
+ return;
259
+ }
260
+
261
+ const result = registerClient(parsed, proxy);
262
+ if (result.status === 201) {
263
+ res.writeHead(201, { 'Content-Type': 'application/json' });
264
+ res.end(JSON.stringify(result.body));
265
+ } else {
266
+ res.writeHead(result.status, { 'Content-Type': 'application/json' });
267
+ res.end(JSON.stringify({ error: result.error, error_description: result.description }));
268
+ }
269
+ return;
270
+ }
271
+
272
+ // Token exchange: forwarded verbatim, so the token stays the issuer's.
273
+ if (auth && proxy && req.method === 'POST' && req.url === TOKEN_PATH) {
274
+ try {
275
+ const body = await readHttpBody(req.iterator({ destroyOnReturn: false }));
276
+ const { token } = await resolveUpstreamEndpoints(auth);
277
+ const upstream = await forwardTokenRequest(
278
+ body.toString('utf8'),
279
+ req.headers.authorization,
280
+ token
281
+ );
282
+
283
+ res.writeHead(upstream.status, { 'Content-Type': upstream.contentType });
284
+ res.end(upstream.body);
285
+ } catch (error) {
286
+ if (error instanceof HttpInputError) {
287
+ res.writeHead(error.status, { 'Content-Type': 'application/json', Connection: 'close' });
288
+ res.end(JSON.stringify({ error: 'invalid_request', error_description: error.message }));
289
+ return;
290
+ }
291
+ console.error(`Token request could not be forwarded: ${error}`);
292
+ res.writeHead(502, { 'Content-Type': 'application/json' });
293
+ res.end(JSON.stringify({ error: 'authorization_server_unavailable' }));
294
+ }
295
+ return;
296
+ }
297
+
298
+ if (req.url !== '/mcp') {
299
+ res.writeHead(404);
300
+ res.end('Not found');
301
+ return;
302
+ }
303
+
304
+ // Every /mcp request carries its own token: a session is not a credential,
305
+ // so an established session must not become a way around the check.
306
+ if (auth) {
307
+ const result = await verifyBearer(req.headers.authorization, auth);
308
+ if (result.ok) {
309
+ callerLabel = result.caller ?? result.subject ?? 'authenticated';
310
+ }
311
+ if (!result.ok) {
312
+ res.writeHead(result.status, {
313
+ 'Content-Type': 'application/json',
314
+ 'WWW-Authenticate': wwwAuthenticate(auth, result),
315
+ });
316
+ res.end(JSON.stringify({ error: result.error, error_description: result.description }));
317
+ return;
318
+ }
319
+ }
320
+
321
+ // Authenticate first, then bound and parse input before the SDK validates it.
322
+ let parsedBody: unknown;
323
+ if (req.method === 'POST') {
324
+ try {
325
+ parsedBody = parseMcpBody(await readHttpBody(req.iterator({ destroyOnReturn: false })));
326
+ } catch (error) {
327
+ const status = error instanceof HttpInputError ? error.status : 400;
328
+ res.writeHead(status, { 'Content-Type': 'application/json', Connection: 'close' });
329
+ res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: {
330
+ code: -32600, message: error instanceof HttpInputError ? error.message : 'Invalid JSON',
331
+ } }));
332
+ return;
333
+ }
334
+ }
335
+
336
+ // Check for existing session
337
+ const sessionId = req.headers['mcp-session-id'] as string | undefined;
338
+
339
+ if (sessionId && sessions.has(sessionId)) {
340
+ // Existing session — route to its transport
341
+ const session = sessions.get(sessionId)!;
342
+ await session.transport.handleRequest(req, res, parsedBody);
343
+ return;
344
+ }
345
+
346
+ // New session (POST without session ID = initialize)
347
+ if (req.method === 'POST' && !sessionId) {
348
+ const transport = new StreamableHTTPServerTransport({
349
+ sessionIdGenerator: () => crypto.randomUUID(),
350
+ });
351
+ const server = createServer();
352
+
353
+ transport.onclose = () => {
354
+ const sid = (transport as unknown as { _sessionId?: string })._sessionId;
355
+ if (sid) sessions.delete(sid);
356
+ server.close();
357
+ };
358
+
359
+ await server.connect(transport);
360
+ await transport.handleRequest(req, res, parsedBody);
361
+
362
+ // Capture the session ID that was generated
363
+ const newSessionId = res.getHeader('mcp-session-id') as string | undefined;
364
+ if (newSessionId) {
365
+ sessions.set(newSessionId, { server, transport });
366
+ }
367
+ return;
368
+ }
369
+
370
+ // Session not found
371
+ res.writeHead(400, { 'Content-Type': 'application/json' });
372
+ res.end(JSON.stringify({ error: 'Invalid or missing session' }));
373
+ };
374
+ }
375
+
376
+ async function startHttp(): Promise<void> {
377
+ const port = parseInt(process.env.PORT || '3000', 10);
378
+ const auth = getAuthConfig();
379
+ const httpServer = createHttpServer(createHttpHandler());
380
+ httpServer.listen(port, () => {
381
+ const mode = auth ? `OAuth required (issuer ${auth.issuer})` : 'unauthenticated';
382
+ console.error(`Bitbucket MCP Server v${VERSION} started (http) on port ${port} — ${mode}`);
383
+ });
384
+ }
385
+
128
386
  async function main(): Promise<void> {
129
387
  // Validate settings on startup
130
388
  try {
@@ -134,16 +392,19 @@ async function main(): Promise<void> {
134
392
  process.exit(1);
135
393
  }
136
394
 
137
- const server = createServer();
138
- const transport = new StdioServerTransport();
139
-
140
- await server.connect(transport);
141
-
142
- // Log to stderr so it doesn't interfere with MCP communication on stdout
143
- console.error(`Bitbucket MCP Server v${VERSION} started`);
395
+ const transport = process.env.MCP_TRANSPORT || (process.argv.includes('--http') ? 'http' : 'stdio');
396
+
397
+ if (transport === 'http') {
398
+ await startHttp();
399
+ } else {
400
+ await startStdio();
401
+ }
144
402
  }
145
403
 
146
- main().catch((error) => {
147
- console.error('Fatal error:', error);
148
- process.exit(1);
149
- });
404
+ // Importing the handler for in-memory contracts must not start a listener.
405
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
406
+ main().catch((error) => {
407
+ console.error('Fatal error:', error);
408
+ process.exit(1);
409
+ });
410
+ }
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Minimal OAuth proxy in front of the upstream authorization server.
3
+ *
4
+ * Why this exists: the MCP authorization spec and Microsoft Entra ID cannot be
5
+ * satisfied at the same time by pointing clients straight at Entra.
6
+ *
7
+ * - RFC 9728 requires the `resource` in our metadata to be the identifier the
8
+ * metadata URL was built from, i.e. https://<host>/mcp. Clients send that as
9
+ * the RFC 8707 `resource` parameter.
10
+ * - Entra rejects exactly that with AADSTS9010010 ("the resource parameter
11
+ * provided in the request doesn't match with the requested scopes"): for Entra
12
+ * the resource is the API's Application ID URI, and it only accepts https
13
+ * identifiers on domains verified in the tenant.
14
+ * - Entra also serves no RFC 8414 metadata and no dynamic client registration.
15
+ *
16
+ * So this server becomes the authorization server clients talk to, and
17
+ * translates on the way through. It is deliberately thin:
18
+ *
19
+ * - it issues nothing and stores nothing — no tokens, no codes, no sessions;
20
+ * - /oauth/authorize redirects to Entra, swapping the scope for the one Entra
21
+ * expects and dropping `resource`, while PKCE and state pass through untouched;
22
+ * - /oauth/token forwards the exchange to Entra verbatim (minus `resource`) and
23
+ * returns its response, so the access token is still Entra's and is still
24
+ * verified against Entra's keys by src/auth.ts.
25
+ *
26
+ * Redirect URIs are checked against an allowlist: this is the one place where a
27
+ * proxy like this can be turned into an open redirect, so it is not optional.
28
+ *
29
+ * Dynamic client registration (RFC 7591) is part of that translation: MCP
30
+ * clients refuse an authorization server without it ("Incompatible auth server:
31
+ * does not support dynamic client registration"), while Entra has no such
32
+ * endpoint. Registration here does not create anything — it hands back the
33
+ * client that already exists in Entra, and only for redirect URIs we accept.
34
+ * The client is registered in Entra as a public client, so there is no secret
35
+ * to hand out: PKCE is what protects the exchange.
36
+ *
37
+ * Environment variables:
38
+ * - MCP_OAUTH_UPSTREAM_SCOPE: scope requested from Entra, e.g.
39
+ * "api://<app-id>/mcp.access offline_access"
40
+ * - MCP_OAUTH_REDIRECT_ALLOWLIST: comma-separated redirect URIs to accept
41
+ * (defaults to Claude's callbacks)
42
+ */
43
+
44
+ import type { AuthConfig } from './auth.js';
45
+ import { authorizationServerMetadata, serverOrigin } from './auth.js';
46
+
47
+ export const AUTHORIZE_PATH = '/oauth/authorize';
48
+ export const TOKEN_PATH = '/oauth/token';
49
+ export const REGISTER_PATH = '/oauth/register';
50
+
51
+ /** Claude's callbacks. Loopback redirects are handled separately, below. */
52
+ const DEFAULT_REDIRECT_ALLOWLIST = [
53
+ 'https://claude.ai/api/mcp/auth_callback',
54
+ 'https://claude.com/api/mcp/auth_callback',
55
+ ];
56
+
57
+ /**
58
+ * Whether we are willing to send the user back to this redirect.
59
+ *
60
+ * Besides the configured list, loopback URIs are accepted: MCP clients that run
61
+ * on the user's own machine (Claude Code among them) listen on an ephemeral port
62
+ * and cannot register it in advance, which is exactly the case RFC 8252 §7.3
63
+ * describes. Only the host is trusted here — the port varies by design, and a
64
+ * loopback address is only reachable from the user's own machine.
65
+ */
66
+ export function isAllowedRedirect(uri: string, allowlist: string[]): boolean {
67
+ if (allowlist.includes(uri)) {
68
+ return true;
69
+ }
70
+
71
+ try {
72
+ const parsed = new URL(uri);
73
+ return (
74
+ parsed.protocol === 'http:' &&
75
+ (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1' || parsed.hostname === '[::1]')
76
+ );
77
+ } catch {
78
+ return false;
79
+ }
80
+ }
81
+
82
+ export interface ProxyConfig {
83
+ /** Public origin of this server; it is the issuer clients see. */
84
+ origin: string;
85
+ /** The client already registered upstream, handed out on registration. */
86
+ clientId: string;
87
+ /** Scope to request upstream, in place of whatever the client asked for. */
88
+ upstreamScope: string;
89
+ /** Redirect URIs we are willing to send users back to. */
90
+ redirectAllowlist: string[];
91
+ }
92
+
93
+ export function getProxyConfig(
94
+ auth: AuthConfig,
95
+ env: NodeJS.ProcessEnv = process.env
96
+ ): ProxyConfig {
97
+ const configured = (env.MCP_OAUTH_REDIRECT_ALLOWLIST || '')
98
+ .split(/[\s,]+/)
99
+ .filter(Boolean);
100
+
101
+ return {
102
+ origin: serverOrigin(auth),
103
+ clientId: (env.MCP_OAUTH_CLIENT_ID || '').trim(),
104
+ upstreamScope:
105
+ (env.MCP_OAUTH_UPSTREAM_SCOPE || '').trim() ||
106
+ [...(auth.advertisedScopes ?? []), 'offline_access'].join(' ').trim(),
107
+ redirectAllowlist: configured.length > 0 ? configured : DEFAULT_REDIRECT_ALLOWLIST,
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Metadata for ourselves as the authorization server (RFC 8414). Clients build
113
+ * the whole flow from this, so the endpoints are ours, not Entra's.
114
+ */
115
+ export function proxyAuthorizationServerMetadata(
116
+ auth: AuthConfig,
117
+ proxy: ProxyConfig
118
+ ): Record<string, unknown> {
119
+ return {
120
+ issuer: proxy.origin,
121
+ authorization_endpoint: `${proxy.origin}${AUTHORIZE_PATH}`,
122
+ token_endpoint: `${proxy.origin}${TOKEN_PATH}`,
123
+ response_types_supported: ['code'],
124
+ grant_types_supported: ['authorization_code', 'refresh_token'],
125
+ code_challenge_methods_supported: ['S256'],
126
+ token_endpoint_auth_methods_supported: ['none'],
127
+ ...(proxy.clientId ? { registration_endpoint: `${proxy.origin}${REGISTER_PATH}` } : {}),
128
+ ...(auth.advertisedScopes ? { scopes_supported: auth.advertisedScopes } : {}),
129
+ };
130
+ }
131
+
132
+ export type AuthorizeResult =
133
+ | { status: 302; location: string }
134
+ | { status: 400; error: string; description: string };
135
+
136
+ /**
137
+ * Turn the client's authorization request into the upstream one.
138
+ *
139
+ * Everything the client chose about the flow — state, PKCE challenge, response
140
+ * type — is passed through unchanged. Only the parameters Entra disagrees with
141
+ * are rewritten: `scope` becomes the API scope, and `resource` is dropped.
142
+ */
143
+ export function buildAuthorizeRedirect(
144
+ requestUrl: URL,
145
+ proxy: ProxyConfig,
146
+ upstreamAuthorizeEndpoint: string
147
+ ): AuthorizeResult {
148
+ const redirectUri = requestUrl.searchParams.get('redirect_uri');
149
+ if (!redirectUri) {
150
+ return { status: 400, error: 'invalid_request', description: 'redirect_uri is required' };
151
+ }
152
+ if (!isAllowedRedirect(redirectUri, proxy.redirectAllowlist)) {
153
+ // An unchecked redirect_uri here would make this an open redirect.
154
+ return {
155
+ status: 400,
156
+ error: 'invalid_request',
157
+ description: 'redirect_uri is not allowed for this server',
158
+ };
159
+ }
160
+
161
+ const upstream = new URL(upstreamAuthorizeEndpoint);
162
+ const passthrough = [
163
+ 'client_id',
164
+ 'response_type',
165
+ 'redirect_uri',
166
+ 'state',
167
+ 'code_challenge',
168
+ 'code_challenge_method',
169
+ 'prompt',
170
+ 'login_hint',
171
+ ];
172
+ for (const name of passthrough) {
173
+ const value = requestUrl.searchParams.get(name);
174
+ if (value !== null) {
175
+ upstream.searchParams.set(name, value);
176
+ }
177
+ }
178
+ upstream.searchParams.set('scope', proxy.upstreamScope);
179
+
180
+ return { status: 302, location: upstream.toString() };
181
+ }
182
+
183
+ /**
184
+ * Body to forward to the upstream token endpoint: the client's own body with
185
+ * `resource` removed, since that is what Entra objects to.
186
+ */
187
+ export function buildTokenRequestBody(rawBody: string): URLSearchParams {
188
+ const params = new URLSearchParams(rawBody);
189
+ params.delete('resource');
190
+ return params;
191
+ }
192
+
193
+ export interface TokenResponse {
194
+ status: number;
195
+ body: string;
196
+ contentType: string;
197
+ }
198
+
199
+ /** Forward the token exchange upstream and hand back whatever it answers. */
200
+ export async function forwardTokenRequest(
201
+ rawBody: string,
202
+ authorizationHeader: string | undefined,
203
+ upstreamTokenEndpoint: string,
204
+ fetchImpl: typeof fetch = fetch
205
+ ): Promise<TokenResponse> {
206
+ const response = await fetchImpl(upstreamTokenEndpoint, {
207
+ method: 'POST',
208
+ headers: {
209
+ 'Content-Type': 'application/x-www-form-urlencoded',
210
+ ...(authorizationHeader ? { Authorization: authorizationHeader } : {}),
211
+ },
212
+ body: buildTokenRequestBody(rawBody).toString(),
213
+ });
214
+
215
+ return {
216
+ status: response.status,
217
+ body: await response.text(),
218
+ contentType: response.headers.get('content-type') ?? 'application/json',
219
+ };
220
+ }
221
+
222
+ /** Upstream endpoints, read from the issuer's own configuration. */
223
+ export async function resolveUpstreamEndpoints(
224
+ auth: AuthConfig,
225
+ fetchImpl: typeof fetch = fetch
226
+ ): Promise<{ authorize: string; token: string }> {
227
+ const metadata = await authorizationServerMetadata(auth, fetchImpl);
228
+ const authorize = metadata.authorization_endpoint;
229
+ const token = metadata.token_endpoint;
230
+
231
+ if (typeof authorize !== 'string' || typeof token !== 'string') {
232
+ throw new Error('Issuer configuration is missing authorization_endpoint or token_endpoint');
233
+ }
234
+
235
+ return { authorize, token };
236
+ }
237
+
238
+ /**
239
+ * A URL safe to write to the log: authorization requests carry state and PKCE
240
+ * challenges, and token responses carry tokens. Neither belongs in a log line.
241
+ */
242
+ export function redactUrlForLog(url: string): string {
243
+ const [path, query] = url.split('?');
244
+ return query ? `${path}?<redacted>` : path;
245
+ }
246
+
247
+ export type RegistrationResult =
248
+ | { status: 201; body: Record<string, unknown> }
249
+ | { status: 400; error: string; description: string };
250
+
251
+ /**
252
+ * Answer a dynamic client registration request with the client that already
253
+ * exists upstream.
254
+ *
255
+ * Nothing is created and nothing is stored. The redirect URIs are checked
256
+ * against the allowlist first: handing our client out for an arbitrary redirect
257
+ * is how this would turn into someone else's authorization flow.
258
+ */
259
+ export function registerClient(request: unknown, proxy: ProxyConfig): RegistrationResult {
260
+ if (!proxy.clientId) {
261
+ return {
262
+ status: 400,
263
+ error: 'invalid_request',
264
+ description: 'This server has no upstream client configured',
265
+ };
266
+ }
267
+
268
+ const body = (request ?? {}) as Record<string, unknown>;
269
+ const redirectUris = Array.isArray(body.redirect_uris) ? body.redirect_uris : [];
270
+
271
+ if (redirectUris.length === 0) {
272
+ return { status: 400, error: 'invalid_redirect_uri', description: 'redirect_uris is required' };
273
+ }
274
+
275
+ const rejected = redirectUris.filter(
276
+ (uri) => typeof uri !== 'string' || !isAllowedRedirect(uri, proxy.redirectAllowlist)
277
+ );
278
+ if (rejected.length > 0) {
279
+ return {
280
+ status: 400,
281
+ error: 'invalid_redirect_uri',
282
+ description: 'redirect_uris are not allowed for this server',
283
+ };
284
+ }
285
+
286
+ return {
287
+ status: 201,
288
+ body: {
289
+ client_id: proxy.clientId,
290
+ redirect_uris: redirectUris,
291
+ token_endpoint_auth_method: 'none',
292
+ grant_types: ['authorization_code', 'refresh_token'],
293
+ response_types: ['code'],
294
+ client_id_issued_at: Math.floor(Date.now() / 1000),
295
+ ...(typeof body.client_name === 'string' ? { client_name: body.client_name } : {}),
296
+ },
297
+ };
298
+ }
package/src/prompts.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * MCP Prompts for Bitbucket Server
3
3
  */
4
4
 
5
- import { Prompt, GetPromptResult, PromptMessage } from '@modelcontextprotocol/sdk/types.js';
5
+ import { Prompt, GetPromptResult } from '@modelcontextprotocol/sdk/types.js';
6
6
 
7
7
  /**
8
8
  * Prompt definitions for the MCP server