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/Dockerfile +9 -1
- package/dist/index.d.ts +16 -0
- package/dist/index.js +906 -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 +253 -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/pipelines.ts +41 -0
- package/src/tools/source.ts +23 -0
- package/src/tools/tags.ts +21 -6
- package/src/types.ts +9 -0
- 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/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.
|
|
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
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
5
|
+
import { Prompt, GetPromptResult } from '@modelcontextprotocol/sdk/types.js';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Prompt definitions for the MCP server
|