@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 +115 -12
- package/build/index.js +130 -53
- package/build/oauth-proxy.js +11 -4
- package/build/schemas.js +2 -2
- package/build/test/mcp-oauth-tests.js +109 -0
- package/build/test/schema-tests.js +77 -3
- package/package.json +1 -1
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
|
|
19
|
+
The server supports four authentication methods:
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
570
|
-
|
|
571
|
-
| `GITLAB_MCP_OAUTH`
|
|
572
|
-
| `GITLAB_OAUTH_APP_ID`
|
|
573
|
-
| `MCP_SERVER_URL`
|
|
574
|
-
| `GITLAB_API_URL`
|
|
575
|
-
| `STREAMABLE_HTTP`
|
|
576
|
-
| `
|
|
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
|
-
//
|
|
536
|
-
|
|
537
|
-
|
|
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
|
|
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
|
|
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:
|
|
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 (
|
|
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
|
-
|
|
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
|
|
8615
|
-
message: "Remote authorization is enabled. Please provide
|
|
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
|
|
8640
|
-
//
|
|
8641
|
-
//
|
|
8642
|
-
//
|
|
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
|
|
8645
|
-
if (
|
|
8646
|
-
if (
|
|
8647
|
-
authBySession[sessionId]
|
|
8648
|
-
|
|
8649
|
-
|
|
8650
|
-
|
|
8651
|
-
|
|
8652
|
-
|
|
8653
|
-
|
|
8654
|
-
|
|
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
|
-
|
|
8657
|
-
|
|
8658
|
-
|
|
8659
|
-
|
|
8660
|
-
|
|
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
|
-
|
|
8694
|
-
|
|
8695
|
-
|
|
8696
|
-
|
|
8697
|
-
|
|
8698
|
-
|
|
8699
|
-
|
|
8700
|
-
|
|
8701
|
-
|
|
8702
|
-
|
|
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
|
},
|
package/build/oauth-proxy.js
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
|
306
|
-
const
|
|
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);
|