@zereight/mcp-gitlab 2.0.35 → 2.0.36

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
@@ -16,10 +16,17 @@ When using with the Claude App, you need to set up your API key and URLs directl
16
16
 
17
17
  #### Authentication Methods
18
18
 
19
- The server supports two authentication methods:
19
+ The server supports four authentication methods:
20
20
 
21
- 1. **Personal Access Token** (traditional method)
22
- 2. **OAuth2** (recommended for better security)
21
+ **For local/desktop use** (most common):
22
+
23
+ 1. **Personal Access Token** (`GITLAB_PERSONAL_ACCESS_TOKEN`) — simplest setup
24
+ 2. **OAuth2 — Local Browser** (`GITLAB_USE_OAUTH`) — recommended for better security
25
+
26
+ **For server/remote deployments**:
27
+
28
+ 3. **OAuth2 — MCP Proxy** (`GITLAB_MCP_OAUTH`) — for remote MCP clients such as Claude.ai
29
+ 4. **Remote Authorization** (`REMOTE_AUTHORIZATION`) — multi-user deployments where each caller provides their own token
23
30
 
24
31
  #### Using OAuth2 Authentication
25
32
 
@@ -325,6 +332,89 @@ docker run -i --rm \
325
332
  }
326
333
  ```
327
334
 
335
+ #### Using MCP OAuth Proxy (`GITLAB_MCP_OAUTH`)
336
+
337
+ > **For server/remote deployments only.** This mode requires the MCP server to be deployed with a publicly accessible HTTPS URL. For local/desktop use, see `GITLAB_USE_OAUTH` above.
338
+
339
+ For remote MCP clients that support the MCP OAuth specification (e.g. Claude.ai).
340
+ The server acts as a full OAuth 2.0 authorization server — unauthenticated requests
341
+ receive a `401 + WWW-Authenticate` response, which triggers the OAuth browser flow
342
+ automatically on the client side.
343
+
344
+ **How it works**: You deploy this MCP server somewhere with a public HTTPS URL. MCP
345
+ clients connect to `{MCP_SERVER_URL}/mcp`. The server handles the OAuth 2.0 flow,
346
+ exchanging credentials with GitLab on behalf of the client.
347
+
348
+ **Prerequisites**:
349
+
350
+ 1. A publicly accessible HTTPS server URL (`MCP_SERVER_URL`) — use [ngrok](https://ngrok.com) for local testing
351
+ 2. A pre-registered GitLab OAuth application with `api` (or `read_api`) scopes
352
+ — Go to `Admin area` → `Applications`, set Redirect URI to `{MCP_SERVER_URL}/callback`
353
+
354
+ | Environment Variable | Required | Description |
355
+ | --------------------- | -------- | ---------------------------------------------------------- |
356
+ | `GITLAB_MCP_OAUTH` | ✅ | Set to `true` to enable |
357
+ | `GITLAB_API_URL` | ✅ | GitLab API base URL |
358
+ | `GITLAB_OAUTH_APP_ID` | ✅ | GitLab OAuth Application ID |
359
+ | `MCP_SERVER_URL` | ✅ | Public HTTPS URL of this MCP server |
360
+ | `STREAMABLE_HTTP` | ✅ | Must be `true` |
361
+ | `GITLAB_OAUTH_SCOPES` | optional | Comma-separated scopes (default: `api,read_api,read_user`) |
362
+
363
+ ```shell
364
+ docker run -i --rm \
365
+ -e HOST=0.0.0.0 \
366
+ -e GITLAB_MCP_OAUTH=true \
367
+ -e STREAMABLE_HTTP=true \
368
+ -e MCP_SERVER_URL=https://your-server.example.com \
369
+ -e GITLAB_API_URL="https://gitlab.com/api/v4" \
370
+ -e GITLAB_OAUTH_APP_ID=your_app_id \
371
+ -p 3000:3002 \
372
+ zereight050/gitlab-mcp
373
+ ```
374
+
375
+ MCP client configuration:
376
+
377
+ ```json
378
+ {
379
+ "mcpServers": {
380
+ "gitlab": {
381
+ "type": "http",
382
+ "url": "https://your-server.example.com/mcp"
383
+ }
384
+ }
385
+ }
386
+ ```
387
+
388
+ #### Using Remote Authorization (`REMOTE_AUTHORIZATION`)
389
+
390
+ > **For server/remote deployments only.** Each HTTP caller provides their own GitLab token directly in request headers — no OAuth flow involved.
391
+
392
+ For multi-user or multi-tenant deployments where each caller provides their own
393
+ GitLab token in the HTTP request header. No OAuth flow — the MCP server forwards
394
+ the token to GitLab on behalf of the caller.
395
+
396
+ **Header priority**: `Private-Token` > `JOB-TOKEN` > `Authorization: Bearer`
397
+
398
+ | Environment Variable | Required | Description |
399
+ | ------------------------ | -------- | ---------------------------------------------------------- |
400
+ | `REMOTE_AUTHORIZATION` | ✅ | Set to `true` to enable |
401
+ | `STREAMABLE_HTTP` | ✅ | Must be `true` |
402
+ | `ENABLE_DYNAMIC_API_URL` | optional | Allow per-request GitLab URL via `X-GitLab-API-URL` header |
403
+
404
+ **Example request headers**:
405
+
406
+ ```http
407
+ Private-Token: glpat-xxxxxxxxxxxxxxxxxxxx
408
+ ```
409
+
410
+ or using a Bearer token:
411
+
412
+ ```http
413
+ Authorization: Bearer glpat-xxxxxxxxxxxxxxxxxxxx
414
+ ```
415
+
416
+ > ⚠️ `REMOTE_AUTHORIZATION` is **not compatible** with SSE transport. `STREAMABLE_HTTP=true` is required.
417
+
328
418
  ### Environment Variables
329
419
 
330
420
  #### Authentication Configuration
@@ -342,6 +432,9 @@ docker run -i --rm \
342
432
  - **SSE transport is disabled** - attempting to use SSE with remote authorization will cause the server to exit with an error
343
433
  - Each client session can use a different token, enabling multi-user support with secure session isolation
344
434
  - Tokens are stored per session and automatically cleaned up when sessions close or timeout
435
+ - `GITLAB_MCP_OAUTH`: Set to `true` to enable the server-side MCP OAuth proxy mode. See [MCP OAuth Setup](#mcp-oauth-setup-claudeai-native-oauth) for details.
436
+ - `GITLAB_OAUTH_APP_ID`: Client ID of the pre-registered GitLab OAuth application. Required when `GITLAB_MCP_OAUTH=true`.
437
+ - `GITLAB_OAUTH_SCOPES`: Comma-separated list of GitLab scopes to request during the MCP OAuth flow (e.g. `api,read_user`). Defaults to `api` (or `read_api` when `GITLAB_READ_ONLY_MODE=true`). Only used when `GITLAB_MCP_OAUTH=true`. The pre-registered application must be configured with at least these scopes.
345
438
  - `SESSION_TIMEOUT_SECONDS`: Session auth token timeout in seconds. Default: `3600` (1 hour). Valid range: 1-86400 seconds (recommended: 60+). After this period of inactivity, the auth token is removed but the transport session remains active. The client must provide auth headers again on the next request. Only applies when `REMOTE_AUTHORIZATION=true`.
346
439
 
347
440
  #### General Configuration
@@ -357,6 +450,7 @@ docker run -i --rm \
357
450
  - `USE_MILESTONE`: Legacy flag. Milestone features are now enabled by default. When set to 'true', ensures milestone-related tools are included even if the `milestones` toolset is not explicitly listed in `GITLAB_TOOLSETS`.
358
451
  - `USE_PIPELINE`: Legacy flag. Pipeline features are now enabled by default. When set to 'true', ensures pipeline-related tools are included even if the `pipelines` toolset is not explicitly listed in `GITLAB_TOOLSETS`.
359
452
  - `GITLAB_TOOLSETS`: Comma-separated list of toolset IDs to enable. When empty or unset, default toolsets are used. Set to `"all"` to enable every toolset. Available toolsets (default toolsets marked with `*`):
453
+
360
454
  - `merge_requests`\* — MR operations, notes, discussions, draft notes, threads, versions, file diffs, conflicts (34 tools)
361
455
  - `issues`\* — Issue CRUD, notes, links, discussions (14 tools)
362
456
  - `repositories`\* — Search, create, file contents, push, fork, tree (7 tools)
@@ -375,11 +469,13 @@ docker run -i --rm \
375
469
  Note: `execute_graphql` is not in any toolset and must be added individually via `GITLAB_TOOLS` if needed.
376
470
  Exposing arbitrary GraphQL would allow bypassing toolset boundaries (e.g. querying data that the user intentionally disabled via toolsets like wiki or pipelines), which is a security and permission-containment concern. Keeping `execute_graphql` out of all toolsets and requiring explicit opt-in via `GITLAB_TOOLS=execute_graphql` is intentional, to align with that principle rather than for backward compatibility.
377
471
  CLI arg: `--toolsets`
472
+
378
473
  - `GITLAB_TOOLS`: Comma-separated list of individual tool names to add on top of the enabled toolsets (additive). Useful for cherry-picking specific tools without enabling an entire toolset. Example: `GITLAB_TOOLS="list_pipelines,execute_graphql"`. CLI arg: `--tools`
379
474
 
380
475
  Combined logic: `final tools = (tools from enabled toolsets) ∪ (GITLAB_TOOLS) ∪ (legacy flag overrides)`
381
476
 
382
477
  Examples:
478
+
383
479
  ```bash
384
480
  # Default behavior (unchanged)
385
481
  GITLAB_PERSONAL_ACCESS_TOKEN=xxx npx @zereight/mcp-gitlab
@@ -399,6 +495,7 @@ docker run -i --rm \
399
495
  # Legacy flags still work (backward compatible)
400
496
  USE_PIPELINE=true npx @zereight/mcp-gitlab
401
497
  ```
498
+
402
499
  - `GITLAB_AUTH_COOKIE_PATH`: Path to an authentication cookie file for GitLab instances that require cookie-based authentication. When provided, the cookie will be included in all GitLab API requests.
403
500
  - `SSE`: When set to 'true', enables the Server-Sent Events transport.
404
501
  - `STREAMABLE_HTTP`: When set to 'true', enables the Streamable HTTP transport. If both **SSE** and **STREAMABLE_HTTP** are set to 'true', the server will prioritize Streamable HTTP over SSE transport.
@@ -512,7 +609,7 @@ calls (need `api` or `read_api`).
512
609
  1. Go to your GitLab instance → **Admin Area > Applications** (instance-wide) or **User Settings > Applications** (personal)
513
610
  2. Create a new application with:
514
611
  - **Confidential**: unchecked
515
- - **Scopes**: `api`, `read_api`, `read_user`
612
+ - **Scopes**: `api`, `read_api`, `read_user` (or whichever scopes you intend to request via `GITLAB_OAUTH_SCOPES`)
516
613
  3. Save and copy the **Application ID** — this is your `GITLAB_OAUTH_APP_ID`
517
614
 
518
615
  **How it works:**
@@ -566,14 +663,15 @@ No `headers` field is needed — Claude.ai obtains the token via OAuth automatic
566
663
 
567
664
  **Environment variables:**
568
665
 
569
- | Variable | Required | Description |
570
- |---|---|---|
571
- | `GITLAB_MCP_OAUTH` | Yes | Set to `true` to enable |
572
- | `GITLAB_OAUTH_APP_ID` | Yes | Client ID of the pre-registered GitLab OAuth application |
573
- | `MCP_SERVER_URL` | Yes | Public HTTPS URL of your MCP server |
574
- | `GITLAB_API_URL` | Yes | Your GitLab instance API URL (e.g. `https://gitlab.com/api/v4`) |
575
- | `STREAMABLE_HTTP` | Yes | Must be `true` (SSE is not supported) |
576
- | `MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL` | No | Set `true` for local HTTP dev only |
666
+ | Variable | Required | Description |
667
+ | ------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
668
+ | `GITLAB_MCP_OAUTH` | Yes | Set to `true` to enable |
669
+ | `GITLAB_OAUTH_APP_ID` | Yes | Client ID of the pre-registered GitLab OAuth application |
670
+ | `MCP_SERVER_URL` | Yes | Public HTTPS URL of your MCP server |
671
+ | `GITLAB_API_URL` | Yes | Your GitLab instance API URL (e.g. `https://gitlab.com/api/v4`) |
672
+ | `STREAMABLE_HTTP` | Yes | Must be `true` (SSE is not supported) |
673
+ | `GITLAB_OAUTH_SCOPES` | No | Comma-separated GitLab scopes to request (e.g. `api,read_user`). Defaults to `api` (or `read_api` when `GITLAB_READ_ONLY_MODE=true`). The pre-registered application must be configured with at least these scopes. |
674
+ | `MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL` | No | Set `true` for local HTTP dev only |
577
675
 
578
676
  **Important Notes:**
579
677
 
@@ -582,6 +680,11 @@ No `headers` field is needed — Claude.ai obtains the token via OAuth automatic
582
680
  - Session timeout, rate limiting, and capacity limits apply identically to the
583
681
  `REMOTE_AUTHORIZATION` mode (`SESSION_TIMEOUT_SECONDS`, `MAX_REQUESTS_PER_MINUTE`,
584
682
  `MAX_SESSIONS`)
683
+ - **Header auth fallback:** when `Private-Token` or `JOB-TOKEN` request headers are
684
+ present, OAuth validation is skipped and the raw token is used directly for that
685
+ session. This allows PATs and CI job tokens to be used alongside the OAuth flow on
686
+ the same server instance. `Authorization: Bearer` is always treated as an OAuth
687
+ token — use `Private-Token` for PAT-based header auth.
585
688
 
586
689
  ## Tools 🛠️
587
690
 
package/build/index.js CHANGED
@@ -155,7 +155,7 @@ function createServer() {
155
155
  // Manually retrieve the session context using the session ID passed in the request.
156
156
  // This is a robust workaround for AsyncLocalStorage context loss.
157
157
  const sessionId = request.params.sessionId;
158
- if (REMOTE_AUTHORIZATION && sessionId && authBySession[sessionId]) {
158
+ if ((REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) && sessionId && authBySession[sessionId]) {
159
159
  const authData = authBySession[sessionId];
160
160
  const sessionContext = {
161
161
  sessionId,
@@ -343,6 +343,10 @@ const REMOTE_AUTHORIZATION = getConfig("remote-auth", "REMOTE_AUTHORIZATION") ==
343
343
  const GITLAB_MCP_OAUTH = getConfig("mcp-oauth", "GITLAB_MCP_OAUTH") === "true";
344
344
  const MCP_SERVER_URL = getConfig("mcp-server-url", "MCP_SERVER_URL");
345
345
  const GITLAB_OAUTH_APP_ID = getConfig("oauth-app-id", "GITLAB_OAUTH_APP_ID");
346
+ const GITLAB_OAUTH_SCOPES_RAW = getConfig("oauth-scopes", "GITLAB_OAUTH_SCOPES");
347
+ const GITLAB_OAUTH_SCOPES = GITLAB_OAUTH_SCOPES_RAW
348
+ ? GITLAB_OAUTH_SCOPES_RAW.split(",").map((s) => s.trim()).filter(Boolean)
349
+ : undefined;
346
350
  const ENABLE_DYNAMIC_API_URL = getConfig("enable-dynamic-api-url", "ENABLE_DYNAMIC_API_URL") === "true";
347
351
  const SESSION_TIMEOUT_SECONDS = Number.parseInt(getConfig("session-timeout", "SESSION_TIMEOUT_SECONDS", "3600"), 10);
348
352
  const HOST = getConfig("host", "HOST") || "127.0.0.1";
@@ -532,11 +536,10 @@ function buildAuthHeaders() {
532
536
  }
533
537
  return {}; // No auth headers if no session context
534
538
  }
535
- // CI job tokens use a dedicated header (not Bearer/Private-Token)
536
- if (GITLAB_JOB_TOKEN) {
537
- return { "JOB-TOKEN": String(GITLAB_JOB_TOKEN) };
538
- }
539
- // Standard mode: prioritize OAuth token, then fall back to environment token
539
+ // Standard mode: PAT preferred over job token (broader permissions).
540
+ // OAuth token takes priority over PAT when both are set.
541
+ // NOTE: Changed in PR #400 — previously GITLAB_JOB_TOKEN had highest priority.
542
+ // If both GITLAB_PERSONAL_ACCESS_TOKEN and GITLAB_JOB_TOKEN are set, PAT wins.
540
543
  const token = OAUTH_ACCESS_TOKEN || GITLAB_PERSONAL_ACCESS_TOKEN;
541
544
  if (IS_OLD && token) {
542
545
  return { "Private-Token": String(token) };
@@ -544,6 +547,10 @@ function buildAuthHeaders() {
544
547
  if (token) {
545
548
  return { Authorization: `Bearer ${token}` };
546
549
  }
550
+ // Fall back to CI job token
551
+ if (GITLAB_JOB_TOKEN) {
552
+ return { "JOB-TOKEN": String(GITLAB_JOB_TOKEN) };
553
+ }
547
554
  return {};
548
555
  }
549
556
  /**
@@ -854,7 +861,7 @@ const allTools = [
854
861
  },
855
862
  {
856
863
  name: "create_issue_note",
857
- description: "Add a new note to an existing issue thread",
864
+ description: "Add a note to an issue. Creates a top-level comment, or replies to a discussion thread if discussion_id is provided",
858
865
  inputSchema: toJSONSchema(CreateIssueNoteSchema),
859
866
  },
860
867
  {
@@ -1833,7 +1840,7 @@ if (GITLAB_MCP_OAUTH) {
1833
1840
  logger.error("Set STREAMABLE_HTTP=true to enable MCP OAuth");
1834
1841
  process.exit(1);
1835
1842
  }
1836
- logger.info("MCP OAuth enabled: GitLab OAuth proxy active");
1843
+ logger.info("MCP OAuth enabled: GitLab OAuth proxy active (Private-Token/JOB-TOKEN headers bypass OAuth)");
1837
1844
  }
1838
1845
  if (!REMOTE_AUTHORIZATION && !GITLAB_MCP_OAUTH && !USE_OAUTH && !GITLAB_PERSONAL_ACCESS_TOKEN && !GITLAB_JOB_TOKEN && !GITLAB_AUTH_COOKIE_PATH) {
1839
1846
  // Standard mode: token must be in environment (unless using OAuth)
@@ -3836,14 +3843,17 @@ async function updateIssueNote(projectId, issueIid, discussionId, noteId, body,
3836
3843
  * Create a note in an issue discussion
3837
3844
  * @param {string} projectId - The ID or URL-encoded path of the project
3838
3845
  * @param {number} issueIid - The IID of an issue
3839
- * @param {string} discussionId - The ID of a thread
3846
+ * @param {string} [discussionId] - The ID of a thread (omit for top-level note)
3840
3847
  * @param {string} body - The content of the new note
3841
3848
  * @param {string} [createdAt] - The creation date of the note (ISO 8601 format)
3842
3849
  * @returns {Promise<GitLabDiscussionNote>} The created note
3843
3850
  */
3844
3851
  async function createIssueNote(projectId, issueIid, discussionId, body, createdAt) {
3845
3852
  projectId = decodeURIComponent(projectId); // Decode project ID
3846
- const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/issues/${issueIid}/discussions/${discussionId}/notes`);
3853
+ const basePath = `${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/issues/${issueIid}`;
3854
+ const url = new URL(discussionId
3855
+ ? `${basePath}/discussions/${discussionId}/notes`
3856
+ : `${basePath}/notes`);
3847
3857
  const payload = { body };
3848
3858
  if (createdAt) {
3849
3859
  payload.created_at = createdAt;
@@ -8464,10 +8474,19 @@ async function startStreamableHTTPServer() {
8464
8474
  session.count++;
8465
8475
  return true;
8466
8476
  };
8477
+ /**
8478
+ * Check whether the request carries a raw header auth token (Private-Token or JOB-TOKEN).
8479
+ * Used to decide whether to bypass OAuth validation.
8480
+ */
8481
+ const hasHeaderAuth = (req) => {
8482
+ return !!(req.headers["private-token"] ||
8483
+ req.headers["job-token"]);
8484
+ };
8467
8485
  /**
8468
8486
  * Parse authentication from request headers
8469
8487
  * Returns null if no auth found or invalid format
8470
- * Supports: JOB-TOKEN header, Private-Token header, Authorization Bearer header
8488
+ * Supports: Private-Token header, JOB-TOKEN header, Authorization Bearer header
8489
+ * Priority: Private-Token > JOB-TOKEN > Authorization Bearer
8471
8490
  */
8472
8491
  const parseAuthHeaders = (req) => {
8473
8492
  const authHeader = req.headers["authorization"] || "";
@@ -8486,17 +8505,18 @@ async function startStreamableHTTPServer() {
8486
8505
  return null; // Reject if URL is malformed
8487
8506
  }
8488
8507
  }
8489
- // Extract token
8508
+ // Extract token — priority: Private-Token > JOB-TOKEN > Authorization Bearer
8509
+ // PATs are preferred over job tokens because they carry broader permissions.
8490
8510
  let token = null;
8491
8511
  let header = null;
8492
- if (jobToken) {
8493
- token = jobToken.trim();
8494
- header = "JOB-TOKEN";
8495
- }
8496
- else if (privateToken) {
8512
+ if (privateToken) {
8497
8513
  token = privateToken.trim();
8498
8514
  header = "Private-Token";
8499
8515
  }
8516
+ else if (jobToken) {
8517
+ token = jobToken.trim();
8518
+ header = "JOB-TOKEN";
8519
+ }
8500
8520
  else if (authHeader) {
8501
8521
  // Use \S+ instead of .+ to prevent ReDoS attacks
8502
8522
  // \S+ only matches non-whitespace, so trim() is technically unnecessary,
@@ -8557,7 +8577,7 @@ async function startStreamableHTTPServer() {
8557
8577
  app.set("trust proxy", 1);
8558
8578
  const gitlabBaseUrl = GITLAB_API_URL.replace(/\/api\/v4\/?$/, "").replace(/\/$/, "");
8559
8579
  const issuerUrl = new URL(MCP_SERVER_URL);
8560
- const oauthProvider = createGitLabOAuthProvider(gitlabBaseUrl, GITLAB_OAUTH_APP_ID, "GitLab MCP Server", GITLAB_READ_ONLY_MODE);
8580
+ const oauthProvider = createGitLabOAuthProvider(gitlabBaseUrl, GITLAB_OAUTH_APP_ID, "GitLab MCP Server", GITLAB_READ_ONLY_MODE, GITLAB_OAUTH_SCOPES);
8561
8581
  // Mounts /.well-known/oauth-authorization-server,
8562
8582
  // /.well-known/oauth-protected-resource,
8563
8583
  // /authorize, /token, /register, /revoke
@@ -8565,7 +8585,7 @@ async function startStreamableHTTPServer() {
8565
8585
  provider: oauthProvider,
8566
8586
  issuerUrl,
8567
8587
  baseUrl: issuerUrl,
8568
- scopesSupported: ["api", "read_api", "read_user"],
8588
+ scopesSupported: GITLAB_OAUTH_SCOPES ?? ["api", "read_api", "read_user"],
8569
8589
  resourceName: "GitLab MCP Server",
8570
8590
  }));
8571
8591
  // Expose provider so the /mcp route middleware can reference it
@@ -8574,11 +8594,36 @@ async function startStreamableHTTPServer() {
8574
8594
  // Build bearer-auth middleware — no-op unless GITLAB_MCP_OAUTH is enabled.
8575
8595
  // Unauthenticated requests receive 401 + WWW-Authenticate header, which is
8576
8596
  // exactly what Claude.ai needs to trigger the OAuth browser flow.
8577
- const mcpBearerAuth = GITLAB_MCP_OAUTH
8597
+ //
8598
+ // Header auth fallback: if Private-Token or JOB-TOKEN headers are present,
8599
+ // OAuth validation is skipped and the raw token is used directly per-session.
8600
+ // Note: Authorization: Bearer is always treated as an OAuth token and goes
8601
+ // through OAuth validation — use Private-Token for PAT-based header auth.
8602
+ const oauthBearerAuth = GITLAB_MCP_OAUTH
8578
8603
  ? requireBearerAuth({
8579
8604
  verifier: app._mcpOAuthProvider,
8580
8605
  requiredScopes: [],
8581
8606
  })
8607
+ : undefined;
8608
+ const mcpBearerAuth = GITLAB_MCP_OAUTH
8609
+ ? (req, res, next) => {
8610
+ const privateToken = req.headers["private-token"] || "";
8611
+ const jobToken = req.headers["job-token"] || "";
8612
+ if (privateToken || jobToken) {
8613
+ // Validate the raw token before bypassing OAuth
8614
+ const authData = parseAuthHeaders(req);
8615
+ if (authData) {
8616
+ next();
8617
+ return;
8618
+ }
8619
+ res.status(401).json({
8620
+ error: "Invalid Private-Token or JOB-TOKEN header",
8621
+ message: "The provided token failed validation. Check the token value and format.",
8622
+ });
8623
+ return;
8624
+ }
8625
+ oauthBearerAuth(req, res, next);
8626
+ }
8582
8627
  : (_req, _res, next) => next();
8583
8628
  // Streamable HTTP endpoint - handles both session creation and message handling
8584
8629
  app.post("/mcp", mcpBearerAuth, async (req, res) => {
@@ -8611,8 +8656,8 @@ async function startStreamableHTTPServer() {
8611
8656
  if (!authData) {
8612
8657
  metrics.authFailures++;
8613
8658
  res.status(401).json({
8614
- error: "Missing Authorization, Private-Token, or JOB-TOKEN header",
8615
- message: "Remote authorization is enabled. Please provide Authorization, Private-Token, or JOB-TOKEN header.",
8659
+ error: "Missing Private-Token, JOB-TOKEN, or Authorization header",
8660
+ message: "Remote authorization is enabled. Please provide Private-Token, JOB-TOKEN, or Authorization header.",
8616
8661
  });
8617
8662
  return;
8618
8663
  }
@@ -8636,28 +8681,49 @@ async function startStreamableHTTPServer() {
8636
8681
  // First request without session - will fail in initialization
8637
8682
  }
8638
8683
  }
8639
- // MCP OAuth mode — token already validated by requireBearerAuth middleware.
8640
- // req.auth is populated by the middleware; store/refresh per session so that
8641
- // buildAuthHeaders() can pick it up via AsyncLocalStorage, exactly like the
8642
- // REMOTE_AUTHORIZATION path.
8684
+ // MCP OAuth mode — either header auth (PAT/job token) or OAuth Bearer token.
8685
+ // Header auth takes precedence: if Private-Token or JOB-TOKEN is present the
8686
+ // OAuth middleware was bypassed and we store the raw token per-session.
8687
+ // Otherwise req.auth is populated by requireBearerAuth; store the OAuth token.
8643
8688
  if (GITLAB_MCP_OAUTH) {
8644
- const authInfo = req.auth;
8645
- if (authInfo?.token && sessionId) {
8646
- if (!authBySession[sessionId]) {
8647
- authBySession[sessionId] = {
8648
- header: "Authorization",
8649
- token: authInfo.token,
8650
- lastUsed: Date.now(),
8651
- apiUrl: GITLAB_API_URL,
8652
- };
8653
- logger.info(`Session ${sessionId}: stored OAuth token (client: ${authInfo.clientId})`);
8654
- setAuthTimeout(sessionId);
8689
+ const headerAuthData = hasHeaderAuth(req) ? parseAuthHeaders(req) : null;
8690
+ if (headerAuthData) {
8691
+ if (headerAuthData && sessionId) {
8692
+ if (!authBySession[sessionId]) {
8693
+ authBySession[sessionId] = headerAuthData;
8694
+ logger.info(`Session ${sessionId}: stored ${headerAuthData.header} header (header auth)`);
8695
+ setAuthTimeout(sessionId);
8696
+ }
8697
+ else {
8698
+ authBySession[sessionId] = {
8699
+ ...authBySession[sessionId],
8700
+ header: headerAuthData.header,
8701
+ token: headerAuthData.token,
8702
+ lastUsed: Date.now(),
8703
+ };
8704
+ setAuthTimeout(sessionId);
8705
+ }
8655
8706
  }
8656
- else {
8657
- // Update token on every request — the client may have refreshed it
8658
- authBySession[sessionId].token = authInfo.token;
8659
- authBySession[sessionId].lastUsed = Date.now();
8660
- setAuthTimeout(sessionId);
8707
+ }
8708
+ else {
8709
+ const authInfo = req.auth;
8710
+ if (authInfo?.token && sessionId) {
8711
+ if (!authBySession[sessionId]) {
8712
+ authBySession[sessionId] = {
8713
+ header: "Authorization",
8714
+ token: authInfo.token,
8715
+ lastUsed: Date.now(),
8716
+ apiUrl: GITLAB_API_URL,
8717
+ };
8718
+ logger.info(`Session ${sessionId}: stored OAuth token (client: ${authInfo.clientId})`);
8719
+ setAuthTimeout(sessionId);
8720
+ }
8721
+ else {
8722
+ // Update token on every request — the client may have refreshed it
8723
+ authBySession[sessionId].token = authInfo.token;
8724
+ authBySession[sessionId].lastUsed = Date.now();
8725
+ setAuthTimeout(sessionId);
8726
+ }
8661
8727
  }
8662
8728
  }
8663
8729
  }
@@ -8688,18 +8754,29 @@ async function startStreamableHTTPServer() {
8688
8754
  setAuthTimeout(newSessionId);
8689
8755
  }
8690
8756
  }
8691
- // Store OAuth token for newly created session in MCP OAuth mode
8757
+ // Store OAuth token for newly created session in MCP OAuth mode.
8758
+ // If Private-Token or JOB-TOKEN headers are present, prefer them.
8692
8759
  if (GITLAB_MCP_OAUTH && !authBySession[newSessionId]) {
8693
- const authInfo = req.auth;
8694
- if (authInfo?.token) {
8695
- authBySession[newSessionId] = {
8696
- header: "Authorization",
8697
- token: authInfo.token,
8698
- lastUsed: Date.now(),
8699
- apiUrl: GITLAB_API_URL,
8700
- };
8701
- logger.info(`Session ${newSessionId}: stored OAuth token (client: ${authInfo.clientId})`);
8702
- setAuthTimeout(newSessionId);
8760
+ if (hasHeaderAuth(req)) {
8761
+ const authData = parseAuthHeaders(req);
8762
+ if (authData) {
8763
+ authBySession[newSessionId] = authData;
8764
+ logger.info(`Session ${newSessionId}: stored ${authData.header} header (header auth)`);
8765
+ setAuthTimeout(newSessionId);
8766
+ }
8767
+ }
8768
+ else {
8769
+ const authInfo = req.auth;
8770
+ if (authInfo?.token) {
8771
+ authBySession[newSessionId] = {
8772
+ header: "Authorization",
8773
+ token: authInfo.token,
8774
+ lastUsed: Date.now(),
8775
+ apiUrl: GITLAB_API_URL,
8776
+ };
8777
+ logger.info(`Session ${newSessionId}: stored OAuth token (client: ${authInfo.clientId})`);
8778
+ setAuthTimeout(newSessionId);
8779
+ }
8703
8780
  }
8704
8781
  }
8705
8782
  },
@@ -82,11 +82,16 @@ class GitLabOAuthServerProvider {
82
82
  _resourceName;
83
83
  _requiredScopes;
84
84
  _clientCache = new BoundedClientCache(CLIENT_CACHE_MAX_SIZE);
85
- constructor(gitlabBaseUrl, gitlabAppId, resourceName, readOnly) {
85
+ constructor(gitlabBaseUrl, gitlabAppId, resourceName, readOnly, customScopes) {
86
86
  this._gitlabBaseUrl = gitlabBaseUrl;
87
87
  this._gitlabAppId = gitlabAppId;
88
88
  this._resourceName = resourceName;
89
- this._requiredScopes = readOnly ? REQUIRED_GITLAB_SCOPES_RO : REQUIRED_GITLAB_SCOPES_RW;
89
+ this._requiredScopes =
90
+ customScopes && customScopes.length > 0
91
+ ? customScopes
92
+ : readOnly
93
+ ? REQUIRED_GITLAB_SCOPES_RO
94
+ : REQUIRED_GITLAB_SCOPES_RW;
90
95
  }
91
96
  // ---- Client store (local DCR) ------------------------------------------
92
97
  get clientsStore() {
@@ -251,7 +256,9 @@ class GitLabOAuthServerProvider {
251
256
  * @param gitlabBaseUrl Root URL of the GitLab instance (no trailing slash, no /api/v4).
252
257
  * @param gitlabAppId Client ID of the pre-registered GitLab OAuth application.
253
258
  * @param resourceName Human-readable name shown on the GitLab consent screen.
259
+ * @param readOnly When true and customScopes is not set, restricts to read_api scope.
260
+ * @param customScopes Explicit list of GitLab scopes to require. Overrides readOnly when set.
254
261
  */
255
- export function createGitLabOAuthProvider(gitlabBaseUrl, gitlabAppId, resourceName = "GitLab MCP Server", readOnly = false) {
256
- return new GitLabOAuthServerProvider(gitlabBaseUrl, gitlabAppId, resourceName, readOnly);
262
+ export function createGitLabOAuthProvider(gitlabBaseUrl, gitlabAppId, resourceName = "GitLab MCP Server", readOnly = false, customScopes) {
263
+ return new GitLabOAuthServerProvider(gitlabBaseUrl, gitlabAppId, resourceName, readOnly, customScopes);
257
264
  }
package/build/schemas.js CHANGED
@@ -1116,10 +1116,10 @@ export const UpdateIssueNoteSchema = ProjectParamsSchema.extend({
1116
1116
  .refine(data => !(data.body !== undefined && data.resolved !== undefined), {
1117
1117
  message: "Only one of 'body' or 'resolved' can be provided, not both",
1118
1118
  });
1119
- // Input schema for adding a note to an existing issue discussion
1119
+ // Input schema for adding a note to an issue (top-level comment or discussion reply)
1120
1120
  export const CreateIssueNoteSchema = ProjectParamsSchema.extend({
1121
1121
  issue_iid: z.coerce.string().describe("The IID of an issue"),
1122
- discussion_id: z.coerce.string().describe("The ID of a thread"),
1122
+ discussion_id: z.coerce.string().optional().describe("The ID of a thread. If provided, replies to that thread; otherwise creates a top-level note"),
1123
1123
  body: z.string().describe("The content of the note or reply"),
1124
1124
  created_at: z.string().optional().describe("Date the note was created at (ISO 8601 format)"),
1125
1125
  });
@@ -15,6 +15,8 @@ import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server
15
15
  // ---------------------------------------------------------------------------
16
16
  const MOCK_OAUTH_TOKEN = "ya29.mock-oauth-token-abcdef123456";
17
17
  const MOCK_CLIENT_ID = "mock-app-uid-from-dcr";
18
+ const MOCK_PAT_TOKEN = "glpat-mockpat-testtoken-abcdef12"; // ≥20 chars, valid charset
19
+ const MOCK_JOB_TOKEN = "mockjobtoken-testenv-1234567890"; // ≥20 chars, valid charset
18
20
  const MOCK_GITLAB_PORT_BASE = 9200;
19
21
  const MCP_SERVER_PORT_BASE = 3200;
20
22
  // ---------------------------------------------------------------------------
@@ -441,3 +443,110 @@ describe("MCP OAuth — createGitLabOAuthProvider", () => {
441
443
  console.log(` ✓ client_name annotated: ${registered.client_name}`);
442
444
  });
443
445
  });
446
+ // ---------------------------------------------------------------------------
447
+ // Test suite: Header auth fallback within GITLAB_MCP_OAUTH mode
448
+ // ---------------------------------------------------------------------------
449
+ describe("MCP OAuth — Header Auth Fallback", () => {
450
+ let mcpUrl;
451
+ let mcpBaseUrl;
452
+ let mockGitLab;
453
+ let servers = [];
454
+ before(async () => {
455
+ const mockPort = await findMockServerPort(MOCK_GITLAB_PORT_BASE + 150);
456
+ mockGitLab = new MockGitLabServer({
457
+ port: mockPort,
458
+ // Include both OAuth token and raw tokens so GitLab API calls succeed
459
+ validTokens: [MOCK_OAUTH_TOKEN, MOCK_PAT_TOKEN, MOCK_JOB_TOKEN],
460
+ });
461
+ await mockGitLab.start();
462
+ const mockGitLabUrl = mockGitLab.getUrl();
463
+ // OAuth endpoints needed for server startup AS metadata
464
+ addOAuthEndpoints(mockGitLab, MOCK_OAUTH_TOKEN, MOCK_CLIENT_ID, mockGitLabUrl);
465
+ const mcpPort = await findAvailablePort(MCP_SERVER_PORT_BASE + 150);
466
+ mcpBaseUrl = `http://${HOST}:${mcpPort}`;
467
+ mcpUrl = `${mcpBaseUrl}/mcp`;
468
+ const server = await launchServer({
469
+ mode: TransportMode.STREAMABLE_HTTP,
470
+ port: mcpPort,
471
+ timeout: 5000,
472
+ env: {
473
+ STREAMABLE_HTTP: "true",
474
+ GITLAB_MCP_OAUTH: "true",
475
+ GITLAB_OAUTH_APP_ID: "test-oauth-app-id",
476
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
477
+ MCP_SERVER_URL: mcpBaseUrl,
478
+ MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL: "true",
479
+ },
480
+ });
481
+ servers.push(server);
482
+ console.log(`Mock GitLab (header-auth): ${mockGitLabUrl}`);
483
+ console.log(`MCP Server (header-auth): ${mcpBaseUrl}`);
484
+ });
485
+ after(async () => {
486
+ cleanupServers(servers);
487
+ if (mockGitLab) {
488
+ await mockGitLab.stop();
489
+ }
490
+ });
491
+ const initBody = JSON.stringify({
492
+ jsonrpc: "2.0",
493
+ id: 1,
494
+ method: "initialize",
495
+ params: {
496
+ protocolVersion: "2024-11-05",
497
+ capabilities: {},
498
+ clientInfo: { name: "test-client", version: "1.0.0" },
499
+ },
500
+ });
501
+ test("POST /mcp with Private-Token header bypasses OAuth and is accepted", async () => {
502
+ const res = await fetch(mcpUrl, {
503
+ method: "POST",
504
+ headers: {
505
+ "Content-Type": "application/json",
506
+ Accept: "application/json, text/event-stream",
507
+ "Private-Token": MOCK_PAT_TOKEN,
508
+ },
509
+ body: initBody,
510
+ });
511
+ assert.notStrictEqual(res.status, 401, "Should not return 401 with valid Private-Token");
512
+ assert.notStrictEqual(res.status, 403, "Should not return 403 with valid Private-Token");
513
+ console.log(` ✓ Private-Token header accepted (status: ${res.status})`);
514
+ });
515
+ test("POST /mcp with JOB-TOKEN header bypasses OAuth and is accepted", async () => {
516
+ const res = await fetch(mcpUrl, {
517
+ method: "POST",
518
+ headers: {
519
+ "Content-Type": "application/json",
520
+ Accept: "application/json, text/event-stream",
521
+ "job-token": MOCK_JOB_TOKEN,
522
+ },
523
+ body: initBody,
524
+ });
525
+ assert.notStrictEqual(res.status, 401, "Should not return 401 with valid JOB-TOKEN");
526
+ assert.notStrictEqual(res.status, 403, "Should not return 403 with valid JOB-TOKEN");
527
+ console.log(` ✓ JOB-TOKEN header accepted (status: ${res.status})`);
528
+ });
529
+ test("POST /mcp with valid OAuth Bearer token still works normally", async () => {
530
+ const res = await fetch(mcpUrl, {
531
+ method: "POST",
532
+ headers: {
533
+ "Content-Type": "application/json",
534
+ Accept: "application/json, text/event-stream",
535
+ Authorization: `Bearer ${MOCK_OAUTH_TOKEN}`,
536
+ },
537
+ body: initBody,
538
+ });
539
+ assert.notStrictEqual(res.status, 401, "OAuth Bearer should still work alongside header auth");
540
+ assert.notStrictEqual(res.status, 403, "Should not return 403 with valid OAuth token");
541
+ console.log(` ✓ OAuth Bearer token still accepted (status: ${res.status})`);
542
+ });
543
+ test("POST /mcp with no auth still returns 401", async () => {
544
+ const res = await fetch(mcpUrl, {
545
+ method: "POST",
546
+ headers: { "Content-Type": "application/json" },
547
+ body: initBody,
548
+ });
549
+ assert.strictEqual(res.status, 401, "Should return 401 with no auth");
550
+ console.log(" ✓ No auth still returns 401");
551
+ });
552
+ });
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env ts-node
2
- import { GetFileContentsSchema, GitLabFileContentSchema, CreatePipelineSchema } from '../schemas.js';
2
+ import { GetFileContentsSchema, GitLabFileContentSchema, CreatePipelineSchema, CreateIssueNoteSchema } from '../schemas.js';
3
3
  function runGetFileContentsSchemaTests() {
4
4
  console.log('🧪 Testing GetFileContentsSchema...');
5
5
  const cases = [
@@ -298,12 +298,86 @@ function runCreatePipelineSchemaTests() {
298
298
  console.log(`\nResults: ${passed} passed, ${failed} failed`);
299
299
  return { passed, failed };
300
300
  }
301
+ function runCreateIssueNoteSchemaTests() {
302
+ console.log('\n🧪 Testing CreateIssueNoteSchema...');
303
+ const cases = [
304
+ {
305
+ name: 'schema:create_issue_note:top-level-note-without-discussion-id',
306
+ input: { project_id: 'my/project', issue_iid: '42', body: 'A comment' },
307
+ expected: { project_id: 'my/project', issue_iid: '42', body: 'A comment', discussion_id: undefined }
308
+ },
309
+ {
310
+ name: 'schema:create_issue_note:reply-with-discussion-id',
311
+ input: { project_id: 'my/project', issue_iid: '42', discussion_id: 'abc123', body: 'A reply' },
312
+ expected: { project_id: 'my/project', issue_iid: '42', discussion_id: 'abc123', body: 'A reply' }
313
+ },
314
+ {
315
+ name: 'schema:create_issue_note:with-created-at',
316
+ input: { project_id: 'my/project', issue_iid: '7', body: 'Note', created_at: '2025-01-01T00:00:00Z' },
317
+ expected: { project_id: 'my/project', issue_iid: '7', body: 'Note', created_at: '2025-01-01T00:00:00Z' }
318
+ },
319
+ {
320
+ name: 'schema:create_issue_note:numeric-issue-iid-coerced',
321
+ input: { project_id: 'my/project', issue_iid: 99, body: 'Coerced' },
322
+ expected: { project_id: 'my/project', issue_iid: '99', body: 'Coerced' }
323
+ },
324
+ {
325
+ name: 'schema:create_issue_note:reject-missing-body',
326
+ input: { project_id: 'my/project', issue_iid: '1' },
327
+ shouldFail: true
328
+ }
329
+ ];
330
+ let passed = 0;
331
+ let failed = 0;
332
+ cases.forEach(testCase => {
333
+ const result = {
334
+ name: testCase.name,
335
+ status: 'failed'
336
+ };
337
+ const parsed = CreateIssueNoteSchema.safeParse(testCase.input);
338
+ if (testCase.shouldFail) {
339
+ if (parsed.success) {
340
+ result.error = 'Expected schema validation to fail';
341
+ }
342
+ else {
343
+ result.status = 'passed';
344
+ }
345
+ }
346
+ else if (parsed.success) {
347
+ const expected = testCase.expected || {};
348
+ const matches = Object.entries(expected).every(([key, value]) => {
349
+ const actual = parsed.data[key];
350
+ return actual === value;
351
+ });
352
+ if (matches) {
353
+ result.status = 'passed';
354
+ }
355
+ else {
356
+ result.error = `Unexpected parsed result: ${JSON.stringify(parsed.data)}`;
357
+ }
358
+ }
359
+ else {
360
+ result.error = parsed.error?.message || 'Schema validation failed';
361
+ }
362
+ if (result.status === 'passed') {
363
+ passed++;
364
+ console.log(`✅ ${result.name}`);
365
+ }
366
+ else {
367
+ failed++;
368
+ console.log(`❌ ${result.name}: ${result.error}`);
369
+ }
370
+ });
371
+ console.log(`\nResults: ${passed} passed, ${failed} failed`);
372
+ return { passed, failed };
373
+ }
301
374
  if (import.meta.url === `file://${process.argv[1]}`) {
302
375
  const getFileContentsResult = runGetFileContentsSchemaTests();
303
376
  const fileContentResult = runGitLabFileContentSchemaTests();
304
377
  const createPipelineResult = runCreatePipelineSchemaTests();
305
- const totalPassed = getFileContentsResult.passed + fileContentResult.passed + createPipelineResult.passed;
306
- const totalFailed = getFileContentsResult.failed + fileContentResult.failed + createPipelineResult.failed;
378
+ const createIssueNoteResult = runCreateIssueNoteSchemaTests();
379
+ const totalPassed = getFileContentsResult.passed + fileContentResult.passed + createPipelineResult.passed + createIssueNoteResult.passed;
380
+ const totalFailed = getFileContentsResult.failed + fileContentResult.failed + createPipelineResult.failed + createIssueNoteResult.failed;
307
381
  console.log(`\nTotal Results: ${totalPassed} passed, ${totalFailed} failed`);
308
382
  if (totalFailed > 0) {
309
383
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zereight/mcp-gitlab",
3
- "version": "2.0.35",
3
+ "version": "2.0.36",
4
4
  "description": "MCP server for using the GitLab API",
5
5
  "license": "MIT",
6
6
  "author": "zereight",