@zereight/mcp-gitlab 2.1.3 → 2.1.5

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/README.md CHANGED
@@ -173,12 +173,14 @@ exchanging credentials with GitLab on behalf of the client.
173
173
  | `GITLAB_OAUTH_APP_ID` | ✅ | GitLab OAuth Application ID |
174
174
  | `MCP_SERVER_URL` | ✅ | Public HTTPS URL of this MCP server |
175
175
  | `STREAMABLE_HTTP` | ✅ | Must be `true` |
176
+ | `GITLAB_OAUTH_CALLBACK_PROXY` | optional | Set to `true` to use the MCP server's fixed `/callback` URL |
176
177
  | `GITLAB_OAUTH_SCOPES` | optional | Comma-separated scopes (default: `api,read_api,read_user`) |
177
178
 
178
179
  ```shell
179
180
  docker run -i --rm \
180
181
  -e HOST=0.0.0.0 \
181
182
  -e GITLAB_MCP_OAUTH=true \
183
+ -e GITLAB_OAUTH_CALLBACK_PROXY=true \
182
184
  -e STREAMABLE_HTTP=true \
183
185
  -e MCP_SERVER_URL=https://your-server.example.com \
184
186
  -e GITLAB_API_URL="https://gitlab.com/api/v4" \
@@ -249,6 +251,7 @@ Commonly referenced variables:
249
251
  - `GITLAB_USE_OAUTH`
250
252
  - `REMOTE_AUTHORIZATION`
251
253
  - `GITLAB_MCP_OAUTH`
254
+ - `GITLAB_OAUTH_CALLBACK_PROXY`
252
255
 
253
256
  The reference document also covers:
254
257
 
@@ -259,6 +262,8 @@ The reference document also covers:
259
262
  - transport and session variables
260
263
  - proxy and TLS variables
261
264
 
265
+ For callback proxy mode details, see [GitLab MCP OAuth Callback Proxy](./docs/oauth-callback-proxy.md).
266
+
262
267
  ### Remote Authorization Setup (Multi-User Support)
263
268
 
264
269
  When using `REMOTE_AUTHORIZATION=true`, the MCP server can support multiple users, each with their own GitLab token passed via HTTP headers. This is useful for:
package/build/config.js CHANGED
@@ -56,6 +56,7 @@ export const GITLAB_OAUTH_SCOPES_RAW = getConfig("oauth-scopes", "GITLAB_OAUTH_S
56
56
  export const GITLAB_OAUTH_SCOPES = GITLAB_OAUTH_SCOPES_RAW
57
57
  ? GITLAB_OAUTH_SCOPES_RAW.split(",").map((s) => s.trim()).filter(Boolean)
58
58
  : undefined;
59
+ export const GITLAB_OAUTH_CALLBACK_PROXY = getConfig("oauth-callback-proxy", "GITLAB_OAUTH_CALLBACK_PROXY") === "true";
59
60
  export const ENABLE_DYNAMIC_API_URL = getConfig("enable-dynamic-api-url", "ENABLE_DYNAMIC_API_URL") === "true";
60
61
  // ---------------------------------------------------------------------------
61
62
  // Session / server settings
package/build/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { getConfig, ENABLE_DYNAMIC_API_URL, GITLAB_AUTH_COOKIE_PATH, GITLAB_CA_CERT_PATH, GITLAB_JOB_TOKEN, GITLAB_MCP_OAUTH, GITLAB_OAUTH_APP_ID, GITLAB_OAUTH_SCOPES, GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_POOL_MAX_SIZE, GITLAB_READ_ONLY_MODE, GITLAB_TOOLSETS_RAW, GITLAB_TOOLS_RAW, HOST, HTTP_PROXY, HTTPS_PROXY, IS_OLD, MCP_SERVER_URL, NODE_TLS_REJECT_UNAUTHORIZED, NO_PROXY, PORT, REMOTE_AUTHORIZATION, SESSION_TIMEOUT_SECONDS, SSE, STREAMABLE_HTTP, USE_GITLAB_WIKI, USE_MILESTONE, USE_OAUTH, USE_PIPELINE, GITLAB_TOOL_POLICY_APPROVE_RAW, GITLAB_TOOL_POLICY_HIDDEN_RAW, } from "./config.js";
2
+ import { getConfig, ENABLE_DYNAMIC_API_URL, GITLAB_AUTH_COOKIE_PATH, GITLAB_CA_CERT_PATH, GITLAB_JOB_TOKEN, GITLAB_MCP_OAUTH, GITLAB_OAUTH_APP_ID, GITLAB_OAUTH_SCOPES, GITLAB_OAUTH_CALLBACK_PROXY, GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_POOL_MAX_SIZE, GITLAB_READ_ONLY_MODE, GITLAB_TOOLSETS_RAW, GITLAB_TOOLS_RAW, HOST, HTTP_PROXY, HTTPS_PROXY, IS_OLD, MCP_SERVER_URL, NODE_TLS_REJECT_UNAUTHORIZED, NO_PROXY, PORT, REMOTE_AUTHORIZATION, SESSION_TIMEOUT_SECONDS, SSE, STREAMABLE_HTTP, USE_GITLAB_WIKI, USE_MILESTONE, USE_OAUTH, USE_PIPELINE, GITLAB_TOOL_POLICY_APPROVE_RAW, GITLAB_TOOL_POLICY_HIDDEN_RAW, } from "./config.js";
3
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
4
  import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
5
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -4863,9 +4863,8 @@ async function cancelPipelineJob(projectId, jobId, force) {
4863
4863
  }
4864
4864
  /**
4865
4865
  * Get the repository tree for a project
4866
- * @param {string} projectId - The ID or URL-encoded path of the project
4867
4866
  * @param {GetRepositoryTreeOptions} options - Options for the tree
4868
- * @returns {Promise<GitLabTreeItem[]>}
4867
+ * @returns Parsed tree items plus optional keyset pagination metadata.
4869
4868
  */
4870
4869
  async function getRepositoryTree(options) {
4871
4870
  options.project_id = decodeURIComponent(options.project_id); // Decode project_id within options
@@ -4890,7 +4889,11 @@ async function getRepositoryTree(options) {
4890
4889
  throw new Error(`Failed to get repository tree: ${response.statusText}`);
4891
4890
  }
4892
4891
  const data = await response.json();
4893
- return z.array(GitLabTreeItemSchema).parse(data);
4892
+ const items = z.array(GitLabTreeItemSchema).parse(data);
4893
+ const next_page_token = response.headers.get("x-next-page-token") ||
4894
+ (options.pagination === "keyset" ? response.headers.get("x-next-page") : null) ||
4895
+ undefined;
4896
+ return { items, next_page_token };
4894
4897
  }
4895
4898
  /**
4896
4899
  * List project milestones in a GitLab project
@@ -6583,9 +6586,18 @@ async function handleToolCall(params) {
6583
6586
  }
6584
6587
  case "get_repository_tree": {
6585
6588
  const args = GetRepositoryTreeSchema.parse(params.arguments);
6586
- const tree = await getRepositoryTree(args);
6589
+ const { items, next_page_token } = await getRepositoryTree(args);
6590
+ const result = args.pagination === "keyset" || next_page_token
6591
+ ? {
6592
+ items,
6593
+ ...(next_page_token ? { next_page_token } : {}),
6594
+ pagination_note: next_page_token
6595
+ ? "Pass next_page_token as page_token with pagination=keyset to retrieve the next page."
6596
+ : "No next_page_token was returned; this is the final keyset page.",
6597
+ }
6598
+ : items;
6587
6599
  return {
6588
- content: [{ type: "text", text: JSON.stringify(tree, null, 2) }],
6600
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
6589
6601
  };
6590
6602
  }
6591
6603
  case "list_pipelines": {
@@ -7359,7 +7371,10 @@ async function startStreamableHTTPServer() {
7359
7371
  app.set("trust proxy", 1);
7360
7372
  const gitlabBaseUrl = GITLAB_API_URL.replace(/\/api\/v4\/?$/, "").replace(/\/$/, "");
7361
7373
  const issuerUrl = new URL(MCP_SERVER_URL);
7362
- const oauthProvider = createGitLabOAuthProvider(gitlabBaseUrl, GITLAB_OAUTH_APP_ID, "GitLab MCP Server", GITLAB_READ_ONLY_MODE, GITLAB_OAUTH_SCOPES);
7374
+ const callbackUrl = GITLAB_OAUTH_CALLBACK_PROXY
7375
+ ? `${issuerUrl.origin}${issuerUrl.pathname.replace(/\/$/, "")}/callback`
7376
+ : undefined;
7377
+ const oauthProvider = createGitLabOAuthProvider(gitlabBaseUrl, GITLAB_OAUTH_APP_ID, "GitLab MCP Server", GITLAB_READ_ONLY_MODE, GITLAB_OAUTH_SCOPES, GITLAB_OAUTH_CALLBACK_PROXY, callbackUrl);
7363
7378
  const scopesSupported = GITLAB_OAUTH_SCOPES ?? ["api", "read_api", "read_user"];
7364
7379
  // When server URL has a path (e.g. behind Kong), the SDK's well-known metadata
7365
7380
  // advertises root-level endpoints. Override to use path-prefixed endpoints.
@@ -7412,6 +7427,14 @@ async function startStreamableHTTPServer() {
7412
7427
  }));
7413
7428
  // Expose provider so the /mcp route middleware can reference it
7414
7429
  app._mcpOAuthProvider = oauthProvider;
7430
+ // Mount /callback route for callback proxy mode
7431
+ if (GITLAB_OAUTH_CALLBACK_PROXY) {
7432
+ const callbackPath = `${issuerUrl.pathname.replace(/\/$/, "")}/callback`;
7433
+ app.get(callbackPath, (req, res, next) => {
7434
+ oauthProvider.handleCallback(req, res).catch(next);
7435
+ });
7436
+ logger.info(`Callback proxy mode enabled — ${callbackPath} route mounted`);
7437
+ }
7415
7438
  }
7416
7439
  // Build bearer-auth middleware — no-op unless GITLAB_MCP_OAUTH is enabled.
7417
7440
  // Unauthenticated requests receive 401 + WWW-Authenticate header, which is
@@ -13,7 +13,26 @@
13
13
  * and handles DCR locally — each MCP client gets a unique virtual client_id
14
14
  * mapped to the real GitLab app.
15
15
  *
16
- * ### Flow
16
+ * ### Flow (callback proxy mode — GITLAB_OAUTH_CALLBACK_PROXY=true)
17
+ *
18
+ * When callback proxy mode is enabled, the MCP server acts as a full OAuth
19
+ * intermediary, similar to the Atlassian MCP's OAuthProxy pattern. Only ONE
20
+ * fixed callback URL needs to be registered with GitLab, regardless of how
21
+ * many MCP clients connect.
22
+ *
23
+ * 1. MCP client calls POST /register (DCR) — proxy stores redirect_uris locally
24
+ * and returns a virtual client_id.
25
+ * 2. MCP client redirects to /authorize — proxy stores the client's original
26
+ * redirect_uri and state, generates its own PKCE pair, then redirects to
27
+ * GitLab using the MCP server's fixed /callback URL as redirect_uri.
28
+ * 3. User authorizes on GitLab — GitLab redirects to the MCP server's /callback.
29
+ * 4. /callback handler exchanges the code with GitLab for tokens, stores them
30
+ * server-side, generates a new proxy auth code, and redirects to the client's
31
+ * original redirect_uri with the proxy code.
32
+ * 5. MCP client calls POST /token with the proxy code — proxy returns the
33
+ * stored GitLab tokens.
34
+ *
35
+ * ### Flow (passthrough mode — default)
17
36
  *
18
37
  * 1. MCP client calls POST /register (DCR) — proxy stores redirect_uris locally
19
38
  * and returns a virtual client_id.
@@ -27,62 +46,81 @@
27
46
  */
28
47
  import { InvalidTokenError, ServerError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
29
48
  import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
30
- import { randomUUID } from "node:crypto";
49
+ import { randomUUID, randomBytes, createHash } from "node:crypto";
31
50
  import { pino } from "pino";
32
51
  const logger = pino({ name: "gitlab-mcp-oauth-proxy" });
33
52
  // ---------------------------------------------------------------------------
34
- // Bounded LRU client cache
53
+ // GitLab OAuth Server Provider
54
+ // ---------------------------------------------------------------------------
55
+ /**
56
+ * Minimum GitLab scopes required for the MCP server to function.
57
+ * Injected into the authorization request when the client does not request them.
58
+ */
59
+ const REQUIRED_GITLAB_SCOPES_RW = ["api"];
60
+ const REQUIRED_GITLAB_SCOPES_RO = ["read_api"];
61
+ // ---------------------------------------------------------------------------
62
+ // Callback proxy mode — pending auth transactions
35
63
  // ---------------------------------------------------------------------------
64
+ const PENDING_AUTH_MAX_SIZE = 1000;
65
+ const PENDING_AUTH_TTL_MS = 10 * 60 * 1000; // 10 minutes
36
66
  const CLIENT_CACHE_MAX_SIZE = 1000;
37
- class BoundedClientCache {
67
+ class BoundedLRUMap {
38
68
  _map = new Map();
39
69
  _maxSize;
40
70
  constructor(maxSize) {
41
71
  this._maxSize = maxSize;
42
72
  }
43
- get(clientId) {
44
- const entry = this._map.get(clientId);
45
- if (entry) {
46
- this._map.delete(clientId);
47
- this._map.set(clientId, entry);
73
+ get(key) {
74
+ const v = this._map.get(key);
75
+ if (v !== undefined) {
76
+ this._map.delete(key);
77
+ this._map.set(key, v);
48
78
  }
49
- return entry;
79
+ return v;
50
80
  }
51
- set(clientId, client) {
52
- if (this._map.has(clientId)) {
53
- this._map.delete(clientId);
54
- }
81
+ /** Get and remove in one operation — for one-time-use entries. */
82
+ getAndDelete(key) {
83
+ const v = this._map.get(key);
84
+ if (v !== undefined)
85
+ this._map.delete(key);
86
+ return v;
87
+ }
88
+ set(key, value) {
89
+ if (this._map.has(key))
90
+ this._map.delete(key);
55
91
  else if (this._map.size >= this._maxSize) {
56
92
  const lruKey = this._map.keys().next().value;
57
93
  if (lruKey !== undefined)
58
94
  this._map.delete(lruKey);
59
95
  }
60
- this._map.set(clientId, client);
96
+ this._map.set(key, value);
97
+ }
98
+ delete(key) {
99
+ return this._map.delete(key);
61
100
  }
62
101
  get size() {
63
102
  return this._map.size;
64
103
  }
65
104
  }
66
- // ---------------------------------------------------------------------------
67
- // GitLab OAuth Server Provider
68
- // ---------------------------------------------------------------------------
69
- /**
70
- * Minimum GitLab scopes required for the MCP server to function.
71
- * Injected into the authorization request when the client does not request them.
72
- */
73
- const REQUIRED_GITLAB_SCOPES_RW = ["api"];
74
- const REQUIRED_GITLAB_SCOPES_RO = ["read_api"];
75
105
  class GitLabOAuthServerProvider {
76
106
  /**
77
- * Tell the SDK not to validate PKCE locally — GitLab handles it.
107
+ * Tell the SDK not to validate PKCE locally.
108
+ * - Passthrough mode: GitLab handles PKCE validation.
109
+ * - Callback proxy mode: we verify the client's PKCE manually in
110
+ * exchangeAuthorizationCode() after looking up stored tokens.
78
111
  */
79
112
  skipLocalPkceValidation = true;
80
113
  _gitlabBaseUrl;
81
114
  _gitlabAppId;
82
115
  _resourceName;
83
116
  _requiredScopes;
84
- _clientCache = new BoundedClientCache(CLIENT_CACHE_MAX_SIZE);
85
- constructor(gitlabBaseUrl, gitlabAppId, resourceName, readOnly, customScopes) {
117
+ _clientCache = new BoundedLRUMap(CLIENT_CACHE_MAX_SIZE);
118
+ // Callback proxy mode fields
119
+ _callbackProxyEnabled;
120
+ _callbackUrl;
121
+ _pendingAuth = new BoundedLRUMap(PENDING_AUTH_MAX_SIZE);
122
+ _storedTokens = new BoundedLRUMap(PENDING_AUTH_MAX_SIZE);
123
+ constructor(gitlabBaseUrl, gitlabAppId, resourceName, readOnly, customScopes, callbackProxyEnabled = false, callbackUrl = "") {
86
124
  this._gitlabBaseUrl = gitlabBaseUrl;
87
125
  this._gitlabAppId = gitlabAppId;
88
126
  this._resourceName = resourceName;
@@ -92,6 +130,14 @@ class GitLabOAuthServerProvider {
92
130
  : readOnly
93
131
  ? REQUIRED_GITLAB_SCOPES_RO
94
132
  : REQUIRED_GITLAB_SCOPES_RW;
133
+ this._callbackProxyEnabled = callbackProxyEnabled;
134
+ this._callbackUrl = callbackUrl;
135
+ if (callbackProxyEnabled && !callbackUrl) {
136
+ throw new Error("callbackUrl is required when callbackProxyEnabled is true");
137
+ }
138
+ if (callbackProxyEnabled) {
139
+ logger.info(`Callback proxy mode enabled — fixed callback URL: ${callbackUrl}`);
140
+ }
95
141
  }
96
142
  // ---- Client store (local DCR) ------------------------------------------
97
143
  get clientsStore() {
@@ -130,7 +176,7 @@ class GitLabOAuthServerProvider {
130
176
  };
131
177
  }
132
178
  // ---- Authorize ---------------------------------------------------------
133
- async authorize(_client, params, res) {
179
+ async authorize(client, params, res) {
134
180
  const scopes = params.scopes ?? [];
135
181
  const hasRequired = this._requiredScopes.some((s) => scopes.includes(s));
136
182
  const effectiveScopes = hasRequired
@@ -138,21 +184,57 @@ class GitLabOAuthServerProvider {
138
184
  : [...new Set([...scopes, ...this._requiredScopes])];
139
185
  // Build the GitLab authorize URL with the REAL app client_id
140
186
  const targetUrl = new URL(`${this._gitlabBaseUrl}/oauth/authorize`);
141
- const searchParams = new URLSearchParams({
142
- client_id: this._gitlabAppId,
143
- response_type: "code",
144
- redirect_uri: params.redirectUri,
145
- code_challenge: params.codeChallenge,
146
- code_challenge_method: "S256",
147
- });
148
- if (params.state)
149
- searchParams.set("state", params.state);
150
- if (effectiveScopes.length)
151
- searchParams.set("scope", effectiveScopes.join(" "));
152
- if (params.resource)
153
- searchParams.set("resource", params.resource.href);
154
- targetUrl.search = searchParams.toString();
155
- logger.info(`authorize: redirecting to GitLab (app: ${this._gitlabAppId}, scopes: ${effectiveScopes.join(" ")})`);
187
+ if (this._callbackProxyEnabled) {
188
+ // --- Callback proxy mode ---
189
+ // Generate a proxy PKCE pair (MCP server ↔ GitLab)
190
+ const proxyCodeVerifier = randomBytes(32).toString("base64url");
191
+ const proxyCodeChallenge = createHash("sha256")
192
+ .update(proxyCodeVerifier)
193
+ .digest("base64url");
194
+ // Use a unique state to correlate the callback
195
+ const proxyState = randomUUID();
196
+ // Store the client's original params so /callback can redirect back
197
+ this._pendingAuth.set(proxyState, {
198
+ clientId: client.client_id,
199
+ clientRedirectUri: params.redirectUri,
200
+ clientState: params.state,
201
+ clientCodeChallenge: params.codeChallenge,
202
+ proxyCodeVerifier,
203
+ createdAt: Date.now(),
204
+ });
205
+ const searchParams = new URLSearchParams({
206
+ client_id: this._gitlabAppId,
207
+ response_type: "code",
208
+ redirect_uri: this._callbackUrl,
209
+ code_challenge: proxyCodeChallenge,
210
+ code_challenge_method: "S256",
211
+ state: proxyState,
212
+ });
213
+ if (effectiveScopes.length)
214
+ searchParams.set("scope", effectiveScopes.join(" "));
215
+ if (params.resource)
216
+ searchParams.set("resource", params.resource.href);
217
+ targetUrl.search = searchParams.toString();
218
+ logger.info(`authorize (callback proxy): redirecting to GitLab with fixed callback URL (app: ${this._gitlabAppId}, scopes: ${effectiveScopes.join(" ")})`);
219
+ }
220
+ else {
221
+ // --- Passthrough mode (original behavior) ---
222
+ const searchParams = new URLSearchParams({
223
+ client_id: this._gitlabAppId,
224
+ response_type: "code",
225
+ redirect_uri: params.redirectUri,
226
+ code_challenge: params.codeChallenge,
227
+ code_challenge_method: "S256",
228
+ });
229
+ if (params.state)
230
+ searchParams.set("state", params.state);
231
+ if (effectiveScopes.length)
232
+ searchParams.set("scope", effectiveScopes.join(" "));
233
+ if (params.resource)
234
+ searchParams.set("resource", params.resource.href);
235
+ targetUrl.search = searchParams.toString();
236
+ logger.info(`authorize: redirecting to GitLab (app: ${this._gitlabAppId}, scopes: ${effectiveScopes.join(" ")})`);
237
+ }
156
238
  res.redirect(targetUrl.toString());
157
239
  }
158
240
  // ---- PKCE challenge (delegated to GitLab) ------------------------------
@@ -160,7 +242,45 @@ class GitLabOAuthServerProvider {
160
242
  return "";
161
243
  }
162
244
  // ---- Token exchange ----------------------------------------------------
163
- async exchangeAuthorizationCode(_client, authorizationCode, codeVerifier, redirectUri, resource) {
245
+ async exchangeAuthorizationCode(client, authorizationCode, codeVerifier, redirectUri, resource) {
246
+ if (this._callbackProxyEnabled) {
247
+ // --- Callback proxy mode ---
248
+ // The authorizationCode is a proxy code we generated in handleCallback().
249
+ // Look up the stored tokens by proxy code.
250
+ const entry = this._storedTokens.get(authorizationCode);
251
+ if (!entry) {
252
+ throw new ServerError("Invalid or expired authorization code");
253
+ }
254
+ // Check TTL before consuming — expired entries can't be retried anyway,
255
+ // but we give a specific error so the client knows to restart the flow.
256
+ if (Date.now() - entry.createdAt > PENDING_AUTH_TTL_MS) {
257
+ this._storedTokens.delete(authorizationCode);
258
+ throw new ServerError("Authorization code expired — please restart the OAuth flow");
259
+ }
260
+ // One-time use: delete after validation
261
+ this._storedTokens.delete(authorizationCode);
262
+ // Bind the proxy code to the client and redirect_uri that initiated
263
+ // /authorize, preserving the normal OAuth authorization-code invariant.
264
+ if (client.client_id !== entry.clientId) {
265
+ throw new ServerError("Invalid client for authorization code");
266
+ }
267
+ if (redirectUri !== entry.clientRedirectUri) {
268
+ throw new ServerError("Invalid redirect_uri for authorization code");
269
+ }
270
+ // Verify client PKCE: the client's code_verifier must match the
271
+ // code_challenge stored during /authorize.
272
+ if (entry.clientCodeChallenge) {
273
+ if (!codeVerifier) {
274
+ throw new ServerError("PKCE code_verifier is required");
275
+ }
276
+ const computed = createHash("sha256").update(codeVerifier).digest("base64url");
277
+ if (computed !== entry.clientCodeChallenge) {
278
+ throw new ServerError("PKCE verification failed");
279
+ }
280
+ }
281
+ return entry.tokens;
282
+ }
283
+ // --- Passthrough mode (original behavior) ---
164
284
  const params = new URLSearchParams({
165
285
  grant_type: "authorization_code",
166
286
  client_id: this._gitlabAppId,
@@ -227,6 +347,86 @@ class GitLabOAuthServerProvider {
227
347
  : undefined,
228
348
  };
229
349
  }
350
+ // ---- Callback handler (callback proxy mode) ----------------------------
351
+ /**
352
+ * Handle the OAuth callback from GitLab.
353
+ * Exchanges the auth code for tokens, stores them, generates a proxy code,
354
+ * and redirects to the MCP client's original callback URL.
355
+ *
356
+ * Mount this as GET /callback in the Express app.
357
+ */
358
+ async handleCallback(req, res) {
359
+ if (!this._callbackProxyEnabled) {
360
+ res.status(404).send("Callback proxy mode is not enabled");
361
+ return;
362
+ }
363
+ const code = req.query.code;
364
+ const state = req.query.state;
365
+ const error = req.query.error;
366
+ if (error) {
367
+ logger.error(`GitLab OAuth error: ${error} — ${req.query.error_description ?? "(no description)"}`);
368
+ res.status(400).send("Authorization failed");
369
+ return;
370
+ }
371
+ if (!code || !state) {
372
+ res.status(400).send("Missing code or state parameter");
373
+ return;
374
+ }
375
+ // Look up the pending auth transaction
376
+ const pending = this._pendingAuth.getAndDelete(state);
377
+ if (!pending) {
378
+ res.status(400).send("Unknown or expired state parameter");
379
+ return;
380
+ }
381
+ // Check TTL
382
+ if (Date.now() - pending.createdAt > PENDING_AUTH_TTL_MS) {
383
+ res.status(400).send("Authorization request expired");
384
+ return;
385
+ }
386
+ // Exchange the GitLab auth code for tokens using the proxy's PKCE verifier
387
+ try {
388
+ const tokenParams = new URLSearchParams({
389
+ grant_type: "authorization_code",
390
+ client_id: this._gitlabAppId,
391
+ code,
392
+ redirect_uri: this._callbackUrl,
393
+ code_verifier: pending.proxyCodeVerifier,
394
+ });
395
+ const tokenResponse = await fetch(`${this._gitlabBaseUrl}/oauth/token`, {
396
+ method: "POST",
397
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
398
+ body: tokenParams.toString(),
399
+ });
400
+ if (!tokenResponse.ok) {
401
+ const body = await tokenResponse.text();
402
+ logger.error(`Callback token exchange failed (${tokenResponse.status}): ${body}`);
403
+ res.status(502).send("Token exchange with GitLab failed");
404
+ return;
405
+ }
406
+ const tokens = OAuthTokensSchema.parse(await tokenResponse.json());
407
+ // Generate a proxy auth code for the MCP client
408
+ const proxyCode = randomUUID();
409
+ this._storedTokens.set(proxyCode, {
410
+ tokens,
411
+ clientId: pending.clientId,
412
+ clientCodeChallenge: pending.clientCodeChallenge,
413
+ clientRedirectUri: pending.clientRedirectUri,
414
+ createdAt: Date.now(),
415
+ });
416
+ // Redirect to the MCP client's original callback URL
417
+ const clientCallback = new URL(pending.clientRedirectUri);
418
+ clientCallback.searchParams.set("code", proxyCode);
419
+ if (pending.clientState) {
420
+ clientCallback.searchParams.set("state", pending.clientState);
421
+ }
422
+ logger.info(`callback: exchanged code with GitLab, redirecting to client callback`);
423
+ res.redirect(clientCallback.toString());
424
+ }
425
+ catch (err) {
426
+ logger.error({ err }, "Callback handler error");
427
+ res.status(500).send("Internal error during token exchange");
428
+ }
429
+ }
230
430
  // ---- Revoke token ------------------------------------------------------
231
431
  async revokeToken(_client, request) {
232
432
  const params = new URLSearchParams({
@@ -258,7 +458,11 @@ class GitLabOAuthServerProvider {
258
458
  * @param resourceName Human-readable name shown on the GitLab consent screen.
259
459
  * @param readOnly When true and customScopes is not set, restricts to read_api scope.
260
460
  * @param customScopes Explicit list of GitLab scopes to require. Overrides readOnly when set.
461
+ * @param callbackProxyEnabled When true, the MCP server handles the OAuth callback internally.
462
+ * Only ONE fixed callback URL needs to be registered with GitLab.
463
+ * @param callbackUrl The fixed callback URL (e.g. https://mcp.example.com/callback).
464
+ * Required when callbackProxyEnabled is true.
261
465
  */
262
- export function createGitLabOAuthProvider(gitlabBaseUrl, gitlabAppId, resourceName = "GitLab MCP Server", readOnly = false, customScopes) {
263
- return new GitLabOAuthServerProvider(gitlabBaseUrl, gitlabAppId, resourceName, readOnly, customScopes);
466
+ export function createGitLabOAuthProvider(gitlabBaseUrl, gitlabAppId, resourceName = "GitLab MCP Server", readOnly = false, customScopes, callbackProxyEnabled = false, callbackUrl = "") {
467
+ return new GitLabOAuthServerProvider(gitlabBaseUrl, gitlabAppId, resourceName, readOnly, customScopes, callbackProxyEnabled, callbackUrl);
264
468
  }
package/build/schemas.js CHANGED
@@ -634,8 +634,14 @@ export const GetRepositoryTreeSchema = z.object({
634
634
  .describe("The name of a repository branch or tag. Defaults to the default branch."),
635
635
  recursive: z.coerce.boolean().optional().describe("Boolean value to get a recursive tree"),
636
636
  per_page: z.coerce.number().optional().describe("Number of results to show per page"),
637
- page_token: z.string().optional().describe("The tree record ID for pagination"),
638
- pagination: z.string().optional().describe("Pagination method (keyset)"),
637
+ page_token: z
638
+ .string()
639
+ .optional()
640
+ .describe("Token for keyset pagination. Use the next_page_token value returned in the previous response to retrieve the next page."),
641
+ pagination: z
642
+ .string()
643
+ .optional()
644
+ .describe("Pagination method. Use 'keyset' for keyset-based pagination (required for repositories with many files). Non-keyset calls keep the legacy array response for backward compatibility; that legacy response shape is deprecated and may be removed in a future major release. Keyset calls return a structured response with items and next_page_token when more pages are available."),
639
645
  });
640
646
  export const GitLabTreeSchema = z.object({
641
647
  id: z.string(), // Changed from sha to match GitLab API