@zereight/mcp-gitlab 2.1.29 → 2.1.38

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.
Files changed (71) hide show
  1. package/README.ko.md +72 -20
  2. package/README.md +120 -82
  3. package/README.zh-CN.md +72 -20
  4. package/build/config.js +13 -0
  5. package/build/downloads/proxy.js +199 -0
  6. package/build/index.js +850 -490
  7. package/build/schemas.js +115 -28
  8. package/build/scripts/check-skill-sync.js +137 -0
  9. package/build/scripts/generate-tool-docs.js +8 -3
  10. package/build/server/metrics.js +84 -0
  11. package/build/server/transport-mode.js +25 -0
  12. package/build/test/callback-proxy-tests.js +1 -1
  13. package/build/test/client-pool-test.js +1 -1
  14. package/build/test/dynamic-api-url-allowlist.test.js +2 -2
  15. package/build/test/dynamic-api-url-test.js +4 -4
  16. package/build/test/dynamic-routing-tests.js +4 -4
  17. package/build/test/group-milestone-schema.test.js +45 -0
  18. package/build/test/mcp-oauth-tests.js +29 -4
  19. package/build/test/mcp-server-name.test.js +87 -0
  20. package/build/test/multi-server-test.js +3 -3
  21. package/build/test/no-proxy-integration-test.js +1 -1
  22. package/build/test/nullable-gitlab-response-fields.test.js +22 -0
  23. package/build/test/path-segment-encoding.test.js +11 -0
  24. package/build/test/remote-auth-simple-test.js +226 -199
  25. package/build/test/server/metrics.test.js +47 -0
  26. package/build/test/sse-auth-guard.test.js +2 -2
  27. package/build/test/stateless/session-id-integration.test.js +3 -3
  28. package/build/test/streamable-http-concurrent-session.test.js +64 -1
  29. package/build/test/streamable-http-dns-rebinding.test.js +185 -0
  30. package/build/test/streamable-http-static-token-auth.test.js +25 -22
  31. package/build/test/streamable-http-unauthenticated-discovery.test.js +28 -15
  32. package/build/test/test-ci-catalog.js +1 -1
  33. package/build/test/test-ci-lint.js +1 -1
  34. package/build/test/test-ci-variables.js +8 -5
  35. package/build/test/test-dependency-proxy.js +11 -7
  36. package/build/test/test-deployment-tools.js +16 -2
  37. package/build/test/test-download-attachment.js +1 -1
  38. package/build/test/test-get-file-blame.js +1 -1
  39. package/build/test/test-geteffectiveprojectid.js +221 -8
  40. package/build/test/test-issue-description-patch.js +1 -1
  41. package/build/test/test-job-artifacts.js +1 -1
  42. package/build/test/test-list-issues.js +1 -1
  43. package/build/test/test-list-merge-requests.js +1 -1
  44. package/build/test/test-list-project-members.js +1 -1
  45. package/build/test/test-merge-request-approval-state-tools.js +1 -1
  46. package/build/test/test-merge-request-pipelines.js +1 -1
  47. package/build/test/test-mr-diffs-filter.js +1 -1
  48. package/build/test/test-mr-file-diffs.js +2 -2
  49. package/build/test/test-oauth-proxy-rate-limit.js +1 -1
  50. package/build/test/test-permission-mode.js +216 -0
  51. package/build/test/test-protected-branches.js +23 -4
  52. package/build/test/test-remote-downloads.js +2 -2
  53. package/build/test/test-search-code.js +1 -1
  54. package/build/test/test-tags.js +1 -1
  55. package/build/test/test-todos.js +1 -1
  56. package/build/test/test-token-optimizations.js +3 -3
  57. package/build/test/test-toolset-filtering.js +3 -3
  58. package/build/test/test-update-issue-slim.js +141 -0
  59. package/build/test/test-upload-markdown.js +1 -1
  60. package/build/test/utils/download-token.test.js +35 -0
  61. package/build/test/utils/forwarded-public-base-url.test.js +9 -1
  62. package/build/test/utils/graphql-query.test.js +64 -1
  63. package/build/test/utils/mock-gitlab-server.js +24 -31
  64. package/build/test/utils/server-launcher.js +1 -2
  65. package/build/test/utils/version-check.test.js +52 -0
  66. package/build/tools/registry.js +93 -5
  67. package/build/utils/download-token.js +71 -0
  68. package/build/utils/forwarded-public-base-url.js +22 -0
  69. package/build/utils/graphql-query.js +109 -12
  70. package/build/utils/version-check.js +40 -0
  71. package/package.json +5 -4
@@ -25,7 +25,7 @@ describe('Dynamic API URL - Multiple GitLab Instances', () => {
25
25
  let servers = [];
26
26
  before(async () => {
27
27
  // Start first mock GitLab server
28
- const mockPort1 = await findMockServerPort(MOCK_GITLAB_PORT_BASE_1);
28
+ const mockPort1 = await findMockServerPort();
29
29
  mockGitLab1 = new MockGitLabServer({
30
30
  port: mockPort1,
31
31
  validTokens: [MOCK_TOKEN_1]
@@ -33,7 +33,7 @@ describe('Dynamic API URL - Multiple GitLab Instances', () => {
33
33
  await mockGitLab1.start();
34
34
  mockGitLabUrl1 = mockGitLab1.getUrl();
35
35
  // Start second mock GitLab server
36
- const mockPort2 = await findMockServerPort(MOCK_GITLAB_PORT_BASE_2);
36
+ const mockPort2 = await findMockServerPort();
37
37
  mockGitLab2 = new MockGitLabServer({
38
38
  port: mockPort2,
39
39
  validTokens: [MOCK_TOKEN_2]
@@ -203,14 +203,14 @@ describe('Dynamic API URL - Connection Pool', () => {
203
203
  let servers = [];
204
204
  before(async () => {
205
205
  // Start mock GitLab servers
206
- const mockPort1 = await findMockServerPort(MOCK_GITLAB_PORT_BASE_1 + 50);
206
+ const mockPort1 = await findMockServerPort();
207
207
  mockGitLab1 = new MockGitLabServer({
208
208
  port: mockPort1,
209
209
  validTokens: [MOCK_TOKEN_1]
210
210
  });
211
211
  await mockGitLab1.start();
212
212
  mockGitLabUrl1 = mockGitLab1.getUrl();
213
- const mockPort2 = await findMockServerPort(MOCK_GITLAB_PORT_BASE_2 + 50);
213
+ const mockPort2 = await findMockServerPort();
214
214
  mockGitLab2 = new MockGitLabServer({
215
215
  port: mockPort2,
216
216
  validTokens: [MOCK_TOKEN_2]
@@ -29,7 +29,7 @@ describe('Dynamic Routing and Authentication Scenarios', () => {
29
29
  // to avoid launchServer overwriting GITLAB_PERSONAL_ACCESS_TOKEN with a different value
30
30
  process.env.GITLAB_TOKEN_TEST = MOCK_TOKEN_DEFAULT;
31
31
  process.env.TEST_PROJECT_ID = '1';
32
- const mockPort = await findMockServerPort(9021);
32
+ const mockPort = await findMockServerPort();
33
33
  mockServer = new MockGitLabServer({ port: mockPort, validTokens: [MOCK_TOKEN_DEFAULT] });
34
34
  await mockServer.start();
35
35
  const mcpPort = await findAvailablePort(3021);
@@ -82,7 +82,7 @@ describe('Dynamic Routing and Authentication Scenarios', () => {
82
82
  let mcpUrl;
83
83
  let mockServer;
84
84
  before(async () => {
85
- const mockPort = await findMockServerPort(9022);
85
+ const mockPort = await findMockServerPort();
86
86
  mockServer = new MockGitLabServer({ port: mockPort, validTokens: [MOCK_TOKEN_HEADER] });
87
87
  await mockServer.start();
88
88
  const mcpPort = await findAvailablePort(3022);
@@ -146,10 +146,10 @@ describe('Dynamic Routing and Authentication Scenarios', () => {
146
146
  let defaultMockServer;
147
147
  let headerMockServer;
148
148
  before(async () => {
149
- const defaultPort = await findMockServerPort(9024);
149
+ const defaultPort = await findMockServerPort();
150
150
  defaultMockServer = new MockGitLabServer({ port: defaultPort, validTokens: [MOCK_TOKEN_DEFAULT, MOCK_TOKEN_HEADER] });
151
151
  await defaultMockServer.start();
152
- const headerPort = await findMockServerPort(9025);
152
+ const headerPort = await findMockServerPort();
153
153
  headerMockServer = new MockGitLabServer({ port: headerPort, validTokens: [MOCK_TOKEN_DEFAULT, MOCK_TOKEN_HEADER] });
154
154
  await headerMockServer.start();
155
155
  const mcpPort = await findAvailablePort(3024);
@@ -0,0 +1,45 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { GitLabGroupMilestonesSchema, GitLabMilestonesSchema, GetGroupMilestoneIssuesSchema, } from "../schemas.js";
4
+ test("group milestone response keeps group_id and rejects missing group_id", () => {
5
+ const parsed = GitLabGroupMilestonesSchema.parse({
6
+ id: 12,
7
+ iid: 3,
8
+ group_id: 16,
9
+ title: "10.0",
10
+ description: null,
11
+ due_date: null,
12
+ start_date: null,
13
+ state: "active",
14
+ updated_at: "2013-10-02T09:24:18Z",
15
+ created_at: "2013-10-02T09:24:18Z",
16
+ expired: false,
17
+ });
18
+ assert.equal(parsed.group_id, "16");
19
+ assert.equal("project_id" in parsed, false);
20
+ // Project schema coerces missing project_id to "undefined" — why group schema exists
21
+ const coerced = GitLabMilestonesSchema.parse({
22
+ id: 12,
23
+ iid: 3,
24
+ group_id: 16,
25
+ title: "10.0",
26
+ description: null,
27
+ due_date: null,
28
+ start_date: null,
29
+ state: "active",
30
+ updated_at: "2013-10-02T09:24:18Z",
31
+ created_at: "2013-10-02T09:24:18Z",
32
+ expired: false,
33
+ });
34
+ assert.equal(coerced.project_id, "undefined");
35
+ });
36
+ test("group milestone issues schema accepts page and per_page", () => {
37
+ const parsed = GetGroupMilestoneIssuesSchema.parse({
38
+ group_id: "16",
39
+ milestone_id: "12",
40
+ page: 2,
41
+ per_page: 50,
42
+ });
43
+ assert.equal(parsed.page, 2);
44
+ assert.equal(parsed.per_page, 50);
45
+ });
@@ -83,7 +83,7 @@ describe("MCP OAuth — Discovery Endpoints", () => {
83
83
  let mockGitLab;
84
84
  let servers = [];
85
85
  before(async () => {
86
- const mockPort = await findMockServerPort(MOCK_GITLAB_PORT_BASE);
86
+ const mockPort = await findMockServerPort();
87
87
  mockGitLab = new MockGitLabServer({
88
88
  port: mockPort,
89
89
  validTokens: [MOCK_OAUTH_TOKEN],
@@ -137,8 +137,20 @@ describe("MCP OAuth — Discovery Endpoints", () => {
137
137
  assert.ok(body.resource, "Should have resource field");
138
138
  console.log(" ✓ Protected resource metadata returned");
139
139
  });
140
+ test("unprefixed MCP_SERVER_URL serves /mcp resource discovery at /.well-known/oauth-protected-resource/mcp", async () => {
141
+ // When MCP_SERVER_URL has no path, the server registers a dedicated route
142
+ // at /.well-known/oauth-protected-resource/mcp (RFC 9728 path-suffixed
143
+ // discovery) so clients connecting to /mcp can discover the resource.
144
+ const issuerUrl = new URL(mcpBaseUrl);
145
+ const res = await fetch(`${mcpBaseUrl}/.well-known/oauth-protected-resource/mcp`);
146
+ assert.strictEqual(res.status, 200, "Should return 200");
147
+ const body = (await res.json());
148
+ assert.strictEqual(body.resource, `${issuerUrl.origin}/mcp`, "resource should be ${origin}/mcp");
149
+ assert.deepStrictEqual(body.authorization_servers, [issuerUrl.href], "authorization_servers should contain the issuer URL");
150
+ console.log(" ✓ Unprefixed MCP_SERVER_URL: /mcp resource discovery returned correct metadata");
151
+ });
140
152
  test("path-prefixed MCP_SERVER_URL serves path-aware discovery metadata", async () => {
141
- const mockPort = await findMockServerPort(MOCK_GITLAB_PORT_BASE + 25);
153
+ const mockPort = await findMockServerPort();
142
154
  const prefixedMockGitLab = new MockGitLabServer({
143
155
  port: mockPort,
144
156
  validTokens: [MOCK_OAUTH_TOKEN],
@@ -196,7 +208,7 @@ describe("MCP OAuth — /mcp Auth Enforcement", () => {
196
208
  let mockGitLab;
197
209
  let servers = [];
198
210
  before(async () => {
199
- const mockPort = await findMockServerPort(MOCK_GITLAB_PORT_BASE + 50);
211
+ const mockPort = await findMockServerPort();
200
212
  mockGitLab = new MockGitLabServer({
201
213
  port: mockPort,
202
214
  validTokens: [MOCK_OAUTH_TOKEN],
@@ -492,7 +504,7 @@ describe("MCP OAuth — Header Auth Fallback", () => {
492
504
  let mockGitLab;
493
505
  let servers = [];
494
506
  before(async () => {
495
- const mockPort = await findMockServerPort(MOCK_GITLAB_PORT_BASE + 150);
507
+ const mockPort = await findMockServerPort();
496
508
  mockGitLab = new MockGitLabServer({
497
509
  port: mockPort,
498
510
  // Include both OAuth token and raw tokens so GitLab API calls succeed
@@ -566,6 +578,19 @@ describe("MCP OAuth — Header Auth Fallback", () => {
566
578
  assert.notStrictEqual(res.status, 403, "Should not return 403 with valid JOB-TOKEN");
567
579
  console.log(` ✓ JOB-TOKEN header accepted (status: ${res.status})`);
568
580
  });
581
+ test("POST /mcp with invalid Private-Token is rejected before session creation", async () => {
582
+ const res = await fetch(mcpUrl, {
583
+ method: "POST",
584
+ headers: {
585
+ "Content-Type": "application/json",
586
+ Accept: "application/json, text/event-stream",
587
+ "Private-Token": "glpat-invalid-token-0000",
588
+ },
589
+ body: initBody,
590
+ });
591
+ assert.strictEqual(res.status, 401);
592
+ assert.strictEqual(res.headers.get("mcp-session-id"), null);
593
+ });
569
594
  test("POST /mcp with valid OAuth Bearer token still works normally", async () => {
570
595
  const res = await fetch(mcpUrl, {
571
596
  method: "POST",
@@ -0,0 +1,87 @@
1
+ import { after, describe, test } from "node:test";
2
+ import assert from "node:assert";
3
+ import { cleanupServers, findAvailablePort, HOST, launchServer, TransportMode, } from "./utils/server-launcher.js";
4
+ async function rawMcpRequest(url, body, headers = {}) {
5
+ const response = await fetch(url, {
6
+ method: "POST",
7
+ headers: {
8
+ "Content-Type": "application/json",
9
+ Accept: "application/json, text/event-stream",
10
+ ...headers,
11
+ },
12
+ body: JSON.stringify(body),
13
+ });
14
+ const text = await response.text();
15
+ const contentType = response.headers.get("content-type") ?? "";
16
+ if (contentType.includes("text/event-stream")) {
17
+ const dataLines = text
18
+ .split("\n")
19
+ .filter(line => line.startsWith("data: "))
20
+ .map(line => line.slice(6));
21
+ return {
22
+ status: response.status,
23
+ data: dataLines.length > 0 ? JSON.parse(dataLines.at(-1)) : null,
24
+ text,
25
+ };
26
+ }
27
+ return {
28
+ status: response.status,
29
+ data: text ? JSON.parse(text) : null,
30
+ text,
31
+ };
32
+ }
33
+ const initializeBody = {
34
+ jsonrpc: "2.0",
35
+ id: 1,
36
+ method: "initialize",
37
+ params: {
38
+ protocolVersion: "2025-03-26",
39
+ capabilities: {},
40
+ clientInfo: { name: "server-name-test", version: "1.0.0" },
41
+ },
42
+ };
43
+ let portOffset = 0;
44
+ async function launchWithServerName(mcpServerName) {
45
+ const port = await findAvailablePort(3700 + portOffset++ * 10);
46
+ const server = await launchServer({
47
+ mode: TransportMode.STREAMABLE_HTTP,
48
+ port,
49
+ timeout: 10_000,
50
+ env: {
51
+ STREAMABLE_HTTP: "true",
52
+ REMOTE_AUTHORIZATION: "true",
53
+ GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY: "true",
54
+ GITLAB_API_URL: "https://gitlab.example.com/api/v4",
55
+ ...(mcpServerName !== undefined ? { MCP_SERVER_NAME: mcpServerName } : {}),
56
+ },
57
+ });
58
+ return { server, mcpUrl: `http://${HOST}:${port}/mcp` };
59
+ }
60
+ describe("MCP_SERVER_NAME", { timeout: 20_000 }, () => {
61
+ let servers = [];
62
+ after(() => {
63
+ cleanupServers(servers);
64
+ servers = [];
65
+ });
66
+ test("defaults to zereight-gitlab-mcp-server when unset", async () => {
67
+ const { server, mcpUrl } = await launchWithServerName();
68
+ servers.push(server);
69
+ const response = await rawMcpRequest(mcpUrl, initializeBody);
70
+ assert.strictEqual(response.status, 200, response.text);
71
+ assert.strictEqual(response.data?.result?.serverInfo?.name, "zereight-gitlab-mcp-server");
72
+ });
73
+ test("reports the overridden name when MCP_SERVER_NAME is set", async () => {
74
+ const { server, mcpUrl } = await launchWithServerName("gitlab-selfhosted-readonly");
75
+ servers.push(server);
76
+ const response = await rawMcpRequest(mcpUrl, initializeBody);
77
+ assert.strictEqual(response.status, 200, response.text);
78
+ assert.strictEqual(response.data?.result?.serverInfo?.name, "gitlab-selfhosted-readonly");
79
+ });
80
+ test("treats a whitespace-only MCP_SERVER_NAME as unset", async () => {
81
+ const { server, mcpUrl } = await launchWithServerName(" ");
82
+ servers.push(server);
83
+ const response = await rawMcpRequest(mcpUrl, initializeBody);
84
+ assert.strictEqual(response.status, 200, response.text);
85
+ assert.strictEqual(response.data?.result?.serverInfo?.name, "zereight-gitlab-mcp-server");
86
+ });
87
+ });
@@ -45,7 +45,7 @@ describe("Single Client Mode (ENABLE_DYNAMIC_API_URL=false)", () => {
45
45
  let mcpUrl;
46
46
  let mockServer1;
47
47
  before(async () => {
48
- const mockPort = await findMockServerPort(9001);
48
+ const mockPort = await findMockServerPort();
49
49
  mockServer1 = new MockGitLabServer({ port: mockPort, validTokens: [MOCK_TOKEN] });
50
50
  mockServer1.addMockHandler('get', '/projects/1', (req, res) => { res.json(project1); });
51
51
  await mockServer1.start();
@@ -105,8 +105,8 @@ describe("Dynamic Client Mode (ENABLE_DYNAMIC_API_URL=true)", () => {
105
105
  let mockServer1;
106
106
  let mockServer2;
107
107
  before(async () => {
108
- const port1 = await findMockServerPort(9011);
109
- const port2 = await findMockServerPort(9012);
108
+ const port1 = await findMockServerPort();
109
+ const port2 = await findMockServerPort();
110
110
  mockServer1 = new MockGitLabServer({ port: port1, validTokens: [MOCK_TOKEN] });
111
111
  mockServer2 = new MockGitLabServer({ port: port2, validTokens: [MOCK_TOKEN] });
112
112
  mockServer1.addMockHandler('get', '/projects/1', (req, res) => { res.json(project1); });
@@ -22,7 +22,7 @@ describe('NO_PROXY Integration Tests', () => {
22
22
  let mockGitLabHost;
23
23
  before(async () => {
24
24
  // Start mock GitLab server
25
- const mockPort = await findMockServerPort(MOCK_GITLAB_PORT_BASE);
25
+ const mockPort = await findMockServerPort();
26
26
  mockGitLab = new MockGitLabServer({
27
27
  port: mockPort,
28
28
  validTokens: [MOCK_TOKEN]
@@ -0,0 +1,22 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { GitLabCompareResultSchema, GitLabProtectedBranchAccessLevelSchema, } from "../schemas.js";
4
+ test("role-based protected-branch access levels accept null user_id/group_id", () => {
5
+ // #575: GitLab returns null for role-based levels, not omitted fields
6
+ const parsed = GitLabProtectedBranchAccessLevelSchema.parse({
7
+ access_level: 40,
8
+ access_level_description: "Maintainers",
9
+ user_id: null,
10
+ group_id: null,
11
+ });
12
+ assert.equal(parsed.user_id, null);
13
+ assert.equal(parsed.group_id, null);
14
+ });
15
+ test("branch compare accepts null commit", () => {
16
+ const parsed = GitLabCompareResultSchema.parse({
17
+ commit: null,
18
+ commits: [],
19
+ diffs: [],
20
+ });
21
+ assert.equal(parsed.commit, null);
22
+ });
@@ -11,6 +11,9 @@ const rawPathSegmentPatterns = [
11
11
  "/draft_notes/${draftNoteId}",
12
12
  "/versions/${versionId}",
13
13
  "/groups/${groupId}",
14
+ "/jobs/${jobId}",
15
+ "/pipelines/${pipelineId}",
16
+ "/milestones/${milestoneId}",
14
17
  ];
15
18
  test("GitLab URL path IDs are encoded before interpolation", () => {
16
19
  for (const pattern of rawPathSegmentPatterns) {
@@ -22,6 +25,14 @@ test("encoding keeps path traversal payloads inside one URL segment", () => {
22
25
  const url = new URL(`https://gitlab.com/api/v4/projects/42/issues/${encodeURIComponent(payload)}/notes`);
23
26
  assert.equal(url.pathname, "/api/v4/projects/42/issues/1%2F..%2F..%2F..%2F..%2Fadmin%2Fci%2Fvariables/notes");
24
27
  });
28
+ test("job_id path traversal stays under /jobs/", () => {
29
+ // GHSA-7c3w-fxgh-frc7 / #587: unencoded job_id with ../ escaped to /api/v4/user
30
+ const payload = "../../../user";
31
+ const url = new URL(`https://gitlab.com/api/v4/projects/1/jobs/${encodeURIComponent(payload)}/trace`);
32
+ assert.equal(url.pathname, "/api/v4/projects/1/jobs/..%2F..%2F..%2Fuser/trace");
33
+ assert.match(url.pathname, /\/jobs\/[^/]+\/trace$/);
34
+ assert.equal(url.pathname.includes("/api/v4/user"), false);
35
+ });
25
36
  test("malformed percent-encoded segments can still be encoded", () => {
26
37
  assert.equal(encodeURIComponent("bad%zz/path"), "bad%25zz%2Fpath");
27
38
  });