@zereight/mcp-gitlab 2.1.70 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/README.ko.md +3 -1
  2. package/README.md +6 -2
  3. package/README.pt-BR.md +1 -1
  4. package/README.zh-CN.md +3 -1
  5. package/build/config.js +12 -4
  6. package/build/deprecated-env.js +47 -0
  7. package/build/index.js +369 -184
  8. package/build/oauth-device-flow.js +80 -0
  9. package/build/oauth.js +135 -18
  10. package/build/schemas.js +1 -1
  11. package/build/server/remote-http-stack.js +39 -0
  12. package/build/tools/discover-activation.js +38 -0
  13. package/build/tools/exposed-tools.js +35 -0
  14. package/build/tools/listed-tools.js +76 -0
  15. package/build/tools/registry.js +226 -57
  16. package/build/tools/tool-profile.js +66 -0
  17. package/build/utils/compact-tool-allowlist.js +14 -0
  18. package/build/utils/logger.js +34 -12
  19. package/package.json +3 -3
  20. package/build/scripts/check-skill-sync.js +0 -143
  21. package/build/scripts/generate-server-card.js +0 -39
  22. package/build/scripts/generate-tool-coverage.js +0 -58
  23. package/build/scripts/generate-tool-docs.js +0 -430
  24. package/build/scripts/tool-coverage/coverage.js +0 -341
  25. package/build/scripts/tool-coverage/coverage.test.js +0 -142
  26. package/build/test/callback-proxy-tests.js +0 -321
  27. package/build/test/cli/args.test.js +0 -175
  28. package/build/test/cli/boolean-flags.test.js +0 -106
  29. package/build/test/cli/compact-mcp-result.test.js +0 -93
  30. package/build/test/cli/credentials.test.js +0 -153
  31. package/build/test/cli/exposure.test.js +0 -104
  32. package/build/test/cli/load-config-snapshot.js +0 -5
  33. package/build/test/cli/output.test.js +0 -74
  34. package/build/test/cli/replay-command.test.js +0 -37
  35. package/build/test/cli/router.test.js +0 -183
  36. package/build/test/client-pool-test.js +0 -109
  37. package/build/test/clients/client.js +0 -34
  38. package/build/test/clients/custom-header-client.js +0 -122
  39. package/build/test/clients/sse-client.js +0 -95
  40. package/build/test/clients/stdio-client.js +0 -113
  41. package/build/test/clients/streamable-http-client.js +0 -95
  42. package/build/test/config-allowed-groups.test.js +0 -97
  43. package/build/test/create-commit-actions.test.js +0 -102
  44. package/build/test/draft-note-schema.test.js +0 -44
  45. package/build/test/dynamic-api-url-allowlist.test.js +0 -121
  46. package/build/test/dynamic-api-url-test.js +0 -304
  47. package/build/test/dynamic-routing-tests.js +0 -495
  48. package/build/test/get-merge-request-discussion-schema.test.js +0 -30
  49. package/build/test/gitlab-api-tool-errors.test.js +0 -112
  50. package/build/test/gitlab-artifact-entry-schema.test.js +0 -44
  51. package/build/test/gitlab-diff-schema.test.js +0 -36
  52. package/build/test/gitlab-pipeline-schedule-schema.test.js +0 -32
  53. package/build/test/gitlab-tag-schema.test.js +0 -51
  54. package/build/test/group-milestone-schema.test.js +0 -45
  55. package/build/test/mcp-oauth-tests.js +0 -905
  56. package/build/test/mcp-server-name.test.js +0 -87
  57. package/build/test/merge-merge-request-schema.test.js +0 -75
  58. package/build/test/multi-server-test.js +0 -190
  59. package/build/test/no-proxy-integration-test.js +0 -183
  60. package/build/test/no-proxy-test.js +0 -149
  61. package/build/test/nullable-gitlab-response-fields.test.js +0 -37
  62. package/build/test/nullish-tool-arguments-schema.test.js +0 -118
  63. package/build/test/oauth-device-flow-tests.js +0 -450
  64. package/build/test/oauth-download-url.test.js +0 -157
  65. package/build/test/oauth-startup.test.js +0 -204
  66. package/build/test/oauth-tests.js +0 -428
  67. package/build/test/path-segment-encoding.test.js +0 -98
  68. package/build/test/remote-auth-simple-test.js +0 -277
  69. package/build/test/remote-auth-tests.js +0 -314
  70. package/build/test/response-masking.test.js +0 -445
  71. package/build/test/schema-tests.js +0 -1679
  72. package/build/test/server/metrics.test.js +0 -47
  73. package/build/test/server/request-helpers.test.js +0 -48
  74. package/build/test/server/version.test.js +0 -13
  75. package/build/test/sse-auth-guard.test.js +0 -96
  76. package/build/test/sse-session-limits.test.js +0 -273
  77. package/build/test/stateless/callback-proxy.test.js +0 -510
  78. package/build/test/stateless/client-id.test.js +0 -177
  79. package/build/test/stateless/codec.test.js +0 -328
  80. package/build/test/stateless/config-ttl.test.js +0 -149
  81. package/build/test/stateless/consumed-proxy-code-cache.test.js +0 -150
  82. package/build/test/stateless/session-id-integration.test.js +0 -675
  83. package/build/test/stateless/session-id.test.js +0 -157
  84. package/build/test/streamable-http-concurrent-session.test.js +0 -171
  85. package/build/test/streamable-http-dns-rebinding.test.js +0 -185
  86. package/build/test/streamable-http-sse-stream.test.js +0 -350
  87. package/build/test/streamable-http-static-token-auth.test.js +0 -157
  88. package/build/test/streamable-http-unauthenticated-discovery.test.js +0 -207
  89. package/build/test/test-all-transport-server.js +0 -255
  90. package/build/test/test-auth-retry.js +0 -195
  91. package/build/test/test-blank-filters.js +0 -156
  92. package/build/test/test-ci-catalog.js +0 -177
  93. package/build/test/test-ci-lint.js +0 -191
  94. package/build/test/test-ci-variables.js +0 -309
  95. package/build/test/test-create-repository.js +0 -120
  96. package/build/test/test-dependency-proxy.js +0 -195
  97. package/build/test/test-deployment-tools.js +0 -463
  98. package/build/test/test-download-attachment.js +0 -190
  99. package/build/test/test-dynamic-project-scope.js +0 -524
  100. package/build/test/test-empty-toolsets.js +0 -535
  101. package/build/test/test-get-file-blame.js +0 -145
  102. package/build/test/test-get-file-contents.js +0 -128
  103. package/build/test/test-geteffectiveprojectid.js +0 -804
  104. package/build/test/test-health-check.js +0 -115
  105. package/build/test/test-issue-description-patch.js +0 -256
  106. package/build/test/test-job-artifacts.js +0 -275
  107. package/build/test/test-json-schema.js +0 -166
  108. package/build/test/test-list-group-members.js +0 -150
  109. package/build/test/test-list-group-merge-requests.js +0 -165
  110. package/build/test/test-list-issues.js +0 -170
  111. package/build/test/test-list-merge-requests.js +0 -201
  112. package/build/test/test-list-milestones.js +0 -145
  113. package/build/test/test-list-project-members.js +0 -132
  114. package/build/test/test-merge-request-approval-state-tools.js +0 -213
  115. package/build/test/test-merge-request-approvals.js +0 -187
  116. package/build/test/test-merge-request-pipelines.js +0 -106
  117. package/build/test/test-mr-diffs-filter.js +0 -132
  118. package/build/test/test-mr-file-diffs.js +0 -251
  119. package/build/test/test-oauth-proxy-rate-limit.js +0 -133
  120. package/build/test/test-orbit.js +0 -155
  121. package/build/test/test-permission-mode.js +0 -424
  122. package/build/test/test-pipeline-schedules.js +0 -484
  123. package/build/test/test-protected-branches.js +0 -174
  124. package/build/test/test-remote-downloads.js +0 -510
  125. package/build/test/test-search-code.js +0 -275
  126. package/build/test/test-snippets.js +0 -735
  127. package/build/test/test-tags.js +0 -220
  128. package/build/test/test-todos.js +0 -195
  129. package/build/test/test-token-optimizations.js +0 -691
  130. package/build/test/test-toolset-filtering.js +0 -499
  131. package/build/test/test-update-issue-slim.js +0 -141
  132. package/build/test/test-update-project.js +0 -112
  133. package/build/test/test-upload-markdown.js +0 -206
  134. package/build/test/test-vulnerabilities.js +0 -399
  135. package/build/test/test-webhooks.js +0 -218
  136. package/build/test/tool-description-quality.test.js +0 -50
  137. package/build/test/utils/bulk-publish-options.test.js +0 -32
  138. package/build/test/utils/download-token.test.js +0 -35
  139. package/build/test/utils/forwarded-public-base-url.test.js +0 -46
  140. package/build/test/utils/gitlab-instance-version.test.js +0 -32
  141. package/build/test/utils/gitlab-version-gate.test.js +0 -74
  142. package/build/test/utils/graphql-query.test.js +0 -168
  143. package/build/test/utils/helpers-path.test.js +0 -104
  144. package/build/test/utils/jmespath-tool-result.test.js +0 -91
  145. package/build/test/utils/masking.test.js +0 -384
  146. package/build/test/utils/merge-request-position.test.js +0 -57
  147. package/build/test/utils/mock-gitlab-server.js +0 -614
  148. package/build/test/utils/proxy-client-ip.test.js +0 -28
  149. package/build/test/utils/redact-sensitive.test.js +0 -32
  150. package/build/test/utils/safe-redirect-fetch.test.js +0 -463
  151. package/build/test/utils/server-launcher.js +0 -231
  152. package/build/test/utils/tool-args.test.js +0 -210
  153. package/build/test/utils/url.test.js +0 -141
  154. package/build/test/utils/version-check.test.js +0 -239
  155. package/build/test/utils/wiki-title.test.js +0 -21
  156. package/build/test-note.js +0 -54
  157. package/build/test-resolve-issue-note.js +0 -127
@@ -1,102 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import { FileOperationSchema } from "../schemas.js";
4
- import { fileOperationsIncludeDeleteOrMove, toGitLabCommitActions, } from "../utils/gitlab-commit-actions.js";
5
- function firstAction(actions) {
6
- const entry = actions[0];
7
- if (!entry) {
8
- throw new Error("expected at least one commit action");
9
- }
10
- return entry;
11
- }
12
- describe("When mapping file operations to GitLab commit actions", () => {
13
- describe("with a plain text file and no per-file encoding", () => {
14
- it("should default the action to create", () => {
15
- const entry = firstAction(toGitLabCommitActions([{ path: "a.txt", content: "hi" }], "text"));
16
- assert.equal(entry.action, "create");
17
- });
18
- it("should keep the content unchanged when the global encoding is text", () => {
19
- const entry = firstAction(toGitLabCommitActions([{ path: "a.txt", content: "hi" }], "text"));
20
- assert.equal(entry.content, "hi");
21
- });
22
- it("should set encoding to the global text value", () => {
23
- const entry = firstAction(toGitLabCommitActions([{ path: "a.txt", content: "hi" }], "text"));
24
- assert.equal(entry.encoding, "text");
25
- });
26
- });
27
- describe("with a plain text file and global base64 encoding", () => {
28
- it("should base64-encode the content", () => {
29
- const entry = firstAction(toGitLabCommitActions([{ path: "a.txt", content: "hi" }], "base64"));
30
- assert.equal(entry.content, Buffer.from("hi").toString("base64"));
31
- });
32
- it("should set encoding to base64", () => {
33
- const entry = firstAction(toGitLabCommitActions([{ path: "a.txt", content: "hi" }], "base64"));
34
- assert.equal(entry.encoding, "base64");
35
- });
36
- });
37
- describe("with an explicit per-file encoding", () => {
38
- it("should pass text content through when encoding is text even if global is base64", () => {
39
- const entry = firstAction(toGitLabCommitActions([{ path: "a.txt", content: "hi", encoding: "text" }], "base64"));
40
- assert.equal(entry.content, "hi");
41
- });
42
- it("should keep the explicit text encoding on the payload", () => {
43
- const entry = firstAction(toGitLabCommitActions([{ path: "a.txt", content: "hi", encoding: "text" }], "base64"));
44
- assert.equal(entry.encoding, "text");
45
- });
46
- it("should pass base64 content through without encoding it again", () => {
47
- const entry = firstAction(toGitLabCommitActions([{ path: "logo.png", content: "aGk=", encoding: "base64" }], "text"));
48
- assert.equal(entry.content, "aGk=");
49
- });
50
- });
51
- describe("with action delete", () => {
52
- it("should omit content from the payload", () => {
53
- const entry = firstAction(toGitLabCommitActions([{ path: "a.txt", action: "delete" }], "text"));
54
- assert.equal("content" in entry, false);
55
- });
56
- it("should omit encoding from the payload", () => {
57
- const entry = firstAction(toGitLabCommitActions([{ path: "a.txt", action: "delete" }], "text"));
58
- assert.equal("encoding" in entry, false);
59
- });
60
- });
61
- describe("with action move", () => {
62
- it("should omit content when the move has no new content", () => {
63
- const entry = firstAction(toGitLabCommitActions([{ path: "b.txt", action: "move", previous_path: "a.txt" }], "text"));
64
- assert.equal("content" in entry, false);
65
- });
66
- it("should include previous_path", () => {
67
- const entry = firstAction(toGitLabCommitActions([{ path: "b.txt", action: "move", previous_path: "a.txt" }], "text"));
68
- assert.equal(entry.previous_path, "a.txt");
69
- });
70
- it("should include content when a move supplies replacement content", () => {
71
- const entry = firstAction(toGitLabCommitActions([{ path: "b.txt", action: "move", previous_path: "a.txt", content: "new" }], "text"));
72
- assert.equal(entry.content, "new");
73
- });
74
- });
75
- describe("with create or update missing content", () => {
76
- it("should reject a create without content before sending an empty file", () => {
77
- assert.throws(() => toGitLabCommitActions([{ path: "a.txt" }], "text"), /content is required when action is 'create'/);
78
- });
79
- it("should reject the FileOperation schema for create without content", () => {
80
- const parsed = FileOperationSchema.safeParse({ path: "a.txt" });
81
- assert.equal(parsed.success, false);
82
- });
83
- });
84
- });
85
- describe("When checking push_files actions against modify mode", () => {
86
- describe("with delete or move entries", () => {
87
- it("should treat delete as blocked", () => {
88
- assert.equal(fileOperationsIncludeDeleteOrMove([{ action: "delete" }]), true);
89
- });
90
- it("should treat move as blocked", () => {
91
- assert.equal(fileOperationsIncludeDeleteOrMove([{ action: "move" }]), true);
92
- });
93
- });
94
- describe("with create or update entries", () => {
95
- it("should allow omitted action", () => {
96
- assert.equal(fileOperationsIncludeDeleteOrMove([{}]), false);
97
- });
98
- it("should allow update", () => {
99
- assert.equal(fileOperationsIncludeDeleteOrMove([{ action: "update" }]), false);
100
- });
101
- });
102
- });
@@ -1,44 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { test } from "node:test";
3
- import { GitLabDraftNoteSchema } from "../schemas.js";
4
- test("draft note schema keeps Draft Notes API fields and drops fake timestamps", () => {
5
- const parsed = GitLabDraftNoteSchema.parse({
6
- id: 42,
7
- author_id: 7,
8
- note: "looks wrong on this line",
9
- line_code: "a1b2c3d4e5f6_12_17",
10
- merge_request_id: 99,
11
- commit_id: "abc123",
12
- discussion_id: null,
13
- position: { new_path: "src/app.ts", new_line: 17 },
14
- resolve_discussion: false,
15
- // Notes-API leftovers that Draft Notes API never returns — must be stripped
16
- created_at: "2024-01-01T00:00:00Z",
17
- updated_at: "2024-01-02T00:00:00Z",
18
- });
19
- assert.equal(parsed.id, "42");
20
- assert.equal(parsed.author_id, 7);
21
- assert.equal(parsed.body, "looks wrong on this line");
22
- assert.equal(parsed.line_code, "a1b2c3d4e5f6_12_17");
23
- assert.equal(parsed.merge_request_id, 99);
24
- assert.equal(parsed.commit_id, "abc123");
25
- assert.equal(parsed.discussion_id, null);
26
- assert.equal(parsed.resolve_discussion, false);
27
- assert.equal("created_at" in parsed, false);
28
- assert.equal("updated_at" in parsed, false);
29
- });
30
- test("draft note schema preserves null line_code for unresolved positions", () => {
31
- const parsed = GitLabDraftNoteSchema.parse({
32
- id: "43",
33
- author_id: 7,
34
- note: "orphan draft",
35
- line_code: null,
36
- merge_request_id: 99,
37
- commit_id: null,
38
- discussion_id: null,
39
- position: null,
40
- resolve_discussion: false,
41
- });
42
- assert.equal(parsed.line_code, null);
43
- assert.equal(parsed.commit_id, null);
44
- });
@@ -1,121 +0,0 @@
1
- import { after, before, describe, test } from "node:test";
2
- import assert from "node:assert";
3
- import { createServer } from "node:http";
4
- import { cleanupServers, findAvailablePort, HOST, launchServer, TransportMode, } from "./utils/server-launcher.js";
5
- import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js";
6
- import { CustomHeaderClient } from "./clients/custom-header-client.js";
7
- const MOCK_TOKEN = "glpat-dynamic-url-token";
8
- async function startAttackerServer(port) {
9
- let hits = 0;
10
- const server = createServer((_req, res) => {
11
- hits++;
12
- res.writeHead(200, { "content-type": "application/json" });
13
- res.end("[]");
14
- });
15
- await new Promise((resolve, reject) => {
16
- server.once("error", reject);
17
- server.listen(port, HOST, () => {
18
- server.off("error", reject);
19
- resolve();
20
- });
21
- });
22
- return { server, getHits: () => hits };
23
- }
24
- describe("Dynamic API URL allowlist", () => {
25
- let primaryGitLab;
26
- let secondaryGitLab;
27
- let attackerServer;
28
- let mcpServer;
29
- let mcpPort;
30
- let mcpUrl;
31
- let secondaryHit = false;
32
- let getAttackerHits = () => 0;
33
- before(async () => {
34
- const primaryPort = await findMockServerPort();
35
- primaryGitLab = new MockGitLabServer({ port: primaryPort, validTokens: [MOCK_TOKEN] });
36
- await primaryGitLab.start();
37
- const secondaryPort = await findMockServerPort();
38
- secondaryGitLab = new MockGitLabServer({ port: secondaryPort, validTokens: [MOCK_TOKEN] });
39
- secondaryGitLab.addMockHandler("get", "/projects/1/issues", (_req, res) => {
40
- secondaryHit = true;
41
- res.json([]);
42
- });
43
- await secondaryGitLab.start();
44
- const attackerPort = await findAvailablePort(9300);
45
- const attacker = await startAttackerServer(attackerPort);
46
- attackerServer = attacker.server;
47
- getAttackerHits = attacker.getHits;
48
- mcpPort = await findAvailablePort(3100);
49
- mcpServer = await launchServer({
50
- mode: TransportMode.STREAMABLE_HTTP,
51
- port: mcpPort,
52
- timeout: 5000,
53
- env: {
54
- STREAMABLE_HTTP: "true",
55
- REMOTE_AUTHORIZATION: "true",
56
- ENABLE_DYNAMIC_API_URL: "true",
57
- GITLAB_API_URL: `${primaryGitLab.getUrl()}/api/v4`,
58
- GITLAB_ALLOWED_HOSTS: `${secondaryGitLab.getUrl()}/api/v4`,
59
- },
60
- });
61
- mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
62
- });
63
- after(async () => {
64
- if (mcpServer)
65
- cleanupServers([mcpServer]);
66
- await primaryGitLab?.stop();
67
- await secondaryGitLab?.stop();
68
- const server = attackerServer;
69
- if (server)
70
- await new Promise(resolve => server.close(() => resolve()));
71
- });
72
- test("allows dynamic API URLs on configured hosts", async () => {
73
- const client = new CustomHeaderClient({
74
- authorization: `Bearer ${MOCK_TOKEN}`,
75
- "x-gitlab-api-url": `${secondaryGitLab.getUrl()}/api/v4`,
76
- });
77
- await client.connect(mcpUrl);
78
- await client.callTool("list_issues", { project_id: "1" });
79
- await client.disconnect();
80
- assert.strictEqual(secondaryHit, true, "allowed dynamic host should receive GitLab calls");
81
- });
82
- test("rejects dynamic API URLs on unconfigured hosts before forwarding tokens", async () => {
83
- const server = attackerServer;
84
- assert.ok(server, "attacker server should be running");
85
- const attackerUrl = `http://${HOST}:${server.address().port}/api/v4`;
86
- const client = new CustomHeaderClient({
87
- authorization: `Bearer ${MOCK_TOKEN}`,
88
- "x-gitlab-api-url": attackerUrl,
89
- });
90
- let connected = false;
91
- try {
92
- await client.connect(mcpUrl);
93
- connected = true;
94
- await client.callTool("list_issues", { project_id: "1" });
95
- }
96
- catch {
97
- // Expected: the session is rejected before any GitLab API request is made.
98
- }
99
- finally {
100
- await client.disconnect();
101
- }
102
- assert.strictEqual(connected, false, "untrusted dynamic host should not initialize a session");
103
- assert.strictEqual(getAttackerHits(), 0, "token-bearing requests must not reach untrusted hosts");
104
- });
105
- test("returns remediation text when download proxy rejects untrusted dynamic hosts", async () => {
106
- const server = attackerServer;
107
- assert.ok(server, "attacker server should be running");
108
- const attackerUrl = `http://${HOST}:${server.address().port}/api/v4`;
109
- const response = await fetch(`http://${HOST}:${mcpPort}/downloads/job-artifacts?project_id=1&job_id=1`, {
110
- headers: {
111
- "Private-Token": MOCK_TOKEN,
112
- "x-gitlab-api-url": attackerUrl,
113
- },
114
- });
115
- assert.strictEqual(response.status, 400);
116
- const body = (await response.json());
117
- assert.match(body.error, /not allowed/);
118
- assert.match(body.error, /GITLAB_ALLOWED_HOSTS/);
119
- assert.strictEqual(getAttackerHits(), 0, "download proxy must not forward to untrusted hosts");
120
- });
121
- });
@@ -1,304 +0,0 @@
1
- /**
2
- * Dynamic GitLab API URL Test Suite
3
- * Tests the ability to connect to multiple GitLab instances via custom headers
4
- */
5
- import { describe, test, after, before } from 'node:test';
6
- import assert from 'node:assert';
7
- import { launchServer, findAvailablePort, cleanupServers, TransportMode, HOST } from './utils/server-launcher.js';
8
- import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server.js';
9
- import { CustomHeaderClient } from './clients/custom-header-client.js';
10
- // Test constants
11
- const MOCK_TOKEN_1 = 'glpat-mock-token-instance-1';
12
- const MOCK_TOKEN_2 = 'glpat-mock-token-instance-2';
13
- // Port ranges
14
- const MOCK_GITLAB_PORT_BASE_1 = 9100;
15
- const MOCK_GITLAB_PORT_BASE_2 = 9200;
16
- const MCP_SERVER_PORT_BASE = 3100;
17
- console.log('🌐 Dynamic GitLab API URL Test Suite');
18
- console.log('');
19
- describe('Dynamic API URL - Multiple GitLab Instances', () => {
20
- let mcpUrl;
21
- let mockGitLab1;
22
- let mockGitLab2;
23
- let mockGitLabUrl1;
24
- let mockGitLabUrl2;
25
- let servers = [];
26
- before(async () => {
27
- // Start first mock GitLab server
28
- const mockPort1 = await findMockServerPort();
29
- mockGitLab1 = new MockGitLabServer({
30
- port: mockPort1,
31
- validTokens: [MOCK_TOKEN_1]
32
- });
33
- await mockGitLab1.start();
34
- mockGitLabUrl1 = mockGitLab1.getUrl();
35
- // Start second mock GitLab server
36
- const mockPort2 = await findMockServerPort();
37
- mockGitLab2 = new MockGitLabServer({
38
- port: mockPort2,
39
- validTokens: [MOCK_TOKEN_2]
40
- });
41
- await mockGitLab2.start();
42
- mockGitLabUrl2 = mockGitLab2.getUrl();
43
- // Start MCP server with dynamic API URL enabled
44
- const mcpPort = await findAvailablePort(MCP_SERVER_PORT_BASE);
45
- const server = await launchServer({
46
- mode: TransportMode.STREAMABLE_HTTP,
47
- port: mcpPort,
48
- timeout: 5000,
49
- env: {
50
- STREAMABLE_HTTP: 'true',
51
- REMOTE_AUTHORIZATION: 'true',
52
- ENABLE_DYNAMIC_API_URL: 'true',
53
- GITLAB_READ_ONLY_MODE: 'true',
54
- }
55
- });
56
- servers.push(server);
57
- mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
58
- console.log(`Mock GitLab Instance 1: ${mockGitLabUrl1}`);
59
- console.log(`Mock GitLab Instance 2: ${mockGitLabUrl2}`);
60
- console.log(`MCP Server: ${mcpUrl}`);
61
- });
62
- after(async () => {
63
- cleanupServers(servers);
64
- if (mockGitLab1) {
65
- await mockGitLab1.stop();
66
- }
67
- if (mockGitLab2) {
68
- await mockGitLab2.stop();
69
- }
70
- });
71
- test('should connect to first GitLab instance with custom API URL', async () => {
72
- const client = new CustomHeaderClient({
73
- 'authorization': `Bearer ${MOCK_TOKEN_1}`,
74
- 'x-gitlab-api-url': mockGitLabUrl1
75
- });
76
- await client.connect(mcpUrl);
77
- const tools = await client.listTools();
78
- assert.ok(tools.tools.length > 0, 'Should have tools');
79
- console.log(` ✓ Connected to instance 1, got ${tools.tools.length} tools`);
80
- await client.disconnect();
81
- });
82
- test('should connect to second GitLab instance with different API URL', async () => {
83
- const client = new CustomHeaderClient({
84
- 'authorization': `Bearer ${MOCK_TOKEN_2}`,
85
- 'x-gitlab-api-url': mockGitLabUrl2
86
- });
87
- await client.connect(mcpUrl);
88
- const tools = await client.listTools();
89
- assert.ok(tools.tools.length > 0, 'Should have tools');
90
- console.log(` ✓ Connected to instance 2, got ${tools.tools.length} tools`);
91
- await client.disconnect();
92
- });
93
- test('should handle multiple concurrent connections to different instances', async () => {
94
- const client1 = new CustomHeaderClient({
95
- 'authorization': `Bearer ${MOCK_TOKEN_1}`,
96
- 'x-gitlab-api-url': mockGitLabUrl1
97
- });
98
- const client2 = new CustomHeaderClient({
99
- 'authorization': `Bearer ${MOCK_TOKEN_2}`,
100
- 'x-gitlab-api-url': mockGitLabUrl2
101
- });
102
- // Connect both clients
103
- await Promise.all([
104
- client1.connect(mcpUrl),
105
- client2.connect(mcpUrl)
106
- ]);
107
- // List tools from both
108
- const [tools1, tools2] = await Promise.all([
109
- client1.listTools(),
110
- client2.listTools()
111
- ]);
112
- assert.ok(tools1.tools.length > 0, 'Instance 1 should have tools');
113
- assert.ok(tools2.tools.length > 0, 'Instance 2 should have tools');
114
- console.log(' ✓ Both instances accessible concurrently');
115
- // Disconnect both
116
- await Promise.all([
117
- client1.disconnect(),
118
- client2.disconnect()
119
- ]);
120
- });
121
- test('should reject invalid API URL format', async () => {
122
- const client = new CustomHeaderClient({
123
- 'authorization': `Bearer ${MOCK_TOKEN_1}`,
124
- 'x-gitlab-api-url': 'not-a-valid-url'
125
- });
126
- try {
127
- await client.connect(mcpUrl);
128
- await client.listTools();
129
- await client.disconnect();
130
- assert.fail('Should have rejected invalid URL');
131
- }
132
- catch (error) {
133
- assert.ok(error instanceof Error);
134
- console.log(' ✓ Correctly rejected invalid URL format');
135
- }
136
- });
137
- test('should reject connection with wrong token for instance', async () => {
138
- const client = new CustomHeaderClient({
139
- 'authorization': `Bearer ${MOCK_TOKEN_1}`, // Token for instance 1
140
- 'x-gitlab-api-url': mockGitLabUrl2 // But connecting to instance 2
141
- });
142
- try {
143
- await client.connect(mcpUrl);
144
- await client.listTools();
145
- await client.disconnect();
146
- assert.fail('Should have rejected wrong token');
147
- }
148
- catch (error) {
149
- assert.ok(error instanceof Error);
150
- console.log(' ✓ Correctly rejected wrong token for instance');
151
- }
152
- });
153
- test('should maintain separate sessions for different API URLs', async () => {
154
- const client1 = new CustomHeaderClient({
155
- 'authorization': `Bearer ${MOCK_TOKEN_1}`,
156
- 'x-gitlab-api-url': mockGitLabUrl1
157
- });
158
- const client2 = new CustomHeaderClient({
159
- 'authorization': `Bearer ${MOCK_TOKEN_2}`,
160
- 'x-gitlab-api-url': mockGitLabUrl2
161
- });
162
- // Connect both
163
- await client1.connect(mcpUrl);
164
- await client2.connect(mcpUrl);
165
- // Get session IDs
166
- const sessionId1 = client1.getSessionId();
167
- const sessionId2 = client2.getSessionId();
168
- assert.ok(sessionId1, 'Session 1 should exist');
169
- assert.ok(sessionId2, 'Session 2 should exist');
170
- assert.notStrictEqual(sessionId1, sessionId2, 'Sessions should be different');
171
- console.log(' ✓ Separate sessions maintained for different instances');
172
- // Make requests from both sessions
173
- const [tools1, tools2] = await Promise.all([
174
- client1.listTools(),
175
- client2.listTools()
176
- ]);
177
- assert.ok(tools1.tools.length > 0, 'Instance 1 should work');
178
- assert.ok(tools2.tools.length > 0, 'Instance 2 should work');
179
- console.log(' ✓ Both sessions work independently');
180
- await client1.disconnect();
181
- await client2.disconnect();
182
- });
183
- test('should normalize API URLs correctly', async () => {
184
- // Test with URL that needs normalization (no /api/v4)
185
- const client = new CustomHeaderClient({
186
- 'authorization': `Bearer ${MOCK_TOKEN_1}`,
187
- 'x-gitlab-api-url': mockGitLabUrl1 // Without /api/v4
188
- });
189
- await client.connect(mcpUrl);
190
- const tools = await client.listTools();
191
- assert.ok(tools.tools.length > 0, 'Should work with normalized URL');
192
- console.log(' ✓ URL normalization works correctly');
193
- await client.disconnect();
194
- });
195
- });
196
- describe('Dynamic API URL - Connection Pool', () => {
197
- let mcpUrl;
198
- let metricsUrl;
199
- let mockGitLab1;
200
- let mockGitLab2;
201
- let mockGitLabUrl1;
202
- let mockGitLabUrl2;
203
- let servers = [];
204
- before(async () => {
205
- // Start mock GitLab servers
206
- const mockPort1 = await findMockServerPort();
207
- mockGitLab1 = new MockGitLabServer({
208
- port: mockPort1,
209
- validTokens: [MOCK_TOKEN_1]
210
- });
211
- await mockGitLab1.start();
212
- mockGitLabUrl1 = mockGitLab1.getUrl();
213
- const mockPort2 = await findMockServerPort();
214
- mockGitLab2 = new MockGitLabServer({
215
- port: mockPort2,
216
- validTokens: [MOCK_TOKEN_2]
217
- });
218
- await mockGitLab2.start();
219
- mockGitLabUrl2 = mockGitLab2.getUrl();
220
- // Start MCP server
221
- const mcpPort = await findAvailablePort(MCP_SERVER_PORT_BASE + 50);
222
- const server = await launchServer({
223
- mode: TransportMode.STREAMABLE_HTTP,
224
- port: mcpPort,
225
- timeout: 5000,
226
- env: {
227
- STREAMABLE_HTTP: 'true',
228
- REMOTE_AUTHORIZATION: 'true',
229
- ENABLE_DYNAMIC_API_URL: 'true',
230
- GITLAB_CLIENT_POOL_SIZE: '5',
231
- GITLAB_CLIENT_IDLE_TIMEOUT: '30000',
232
- }
233
- });
234
- servers.push(server);
235
- mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
236
- metricsUrl = `http://${HOST}:${mcpPort}/metrics.json`;
237
- console.log(`MCP Server: ${mcpUrl}`);
238
- console.log(`Metrics URL: ${metricsUrl}`);
239
- });
240
- after(async () => {
241
- cleanupServers(servers);
242
- if (mockGitLab1) {
243
- await mockGitLab1.stop();
244
- }
245
- if (mockGitLab2) {
246
- await mockGitLab2.stop();
247
- }
248
- });
249
- test('should track pool statistics via metrics JSON endpoint', async () => {
250
- // Make some connections first
251
- const client1 = new CustomHeaderClient({
252
- 'authorization': `Bearer ${MOCK_TOKEN_1}`,
253
- 'x-gitlab-api-url': mockGitLabUrl1
254
- });
255
- const client2 = new CustomHeaderClient({
256
- 'authorization': `Bearer ${MOCK_TOKEN_2}`,
257
- 'x-gitlab-api-url': mockGitLabUrl2
258
- });
259
- await client1.connect(mcpUrl);
260
- await client2.connect(mcpUrl);
261
- await client1.listTools();
262
- await client2.listTools();
263
- // Check metrics
264
- const response = await fetch(metricsUrl);
265
- assert.ok(response.ok, 'Metrics JSON endpoint should be accessible');
266
- const metrics = await response.json();
267
- assert.ok(metrics.gitlabClientPool, 'Should have pool metrics');
268
- assert.ok(typeof metrics.gitlabClientPool.size === 'number', 'Should have pool size');
269
- assert.ok(typeof metrics.gitlabClientPool.maxSize === 'number', 'Should have max size');
270
- console.log(' ✓ Pool metrics available');
271
- console.log(` ℹ️ Pool size: ${metrics.gitlabClientPool.size}/${metrics.gitlabClientPool.maxSize}`);
272
- await client1.disconnect();
273
- await client2.disconnect();
274
- });
275
- test('should reuse connections for same API URL', async () => {
276
- // Get initial metrics
277
- const response1 = await fetch(metricsUrl);
278
- const metrics1 = await response1.json();
279
- const initialSize = metrics1.gitlabClientPool?.size || 0;
280
- // Create multiple clients to same instance
281
- const clients = [];
282
- for (let i = 0; i < 3; i++) {
283
- const client = new CustomHeaderClient({
284
- 'authorization': `Bearer ${MOCK_TOKEN_1}`,
285
- 'x-gitlab-api-url': mockGitLabUrl1
286
- });
287
- await client.connect(mcpUrl);
288
- await client.listTools();
289
- clients.push(client);
290
- }
291
- // Check metrics - should not have created 3 new pool entries
292
- const response2 = await fetch(metricsUrl);
293
- const metrics2 = await response2.json();
294
- const finalSize = metrics2.gitlabClientPool?.size || 0;
295
- // Pool size should increase by at most 1 (for the shared URL)
296
- assert.ok(finalSize - initialSize <= 1, 'Should reuse connection for same URL');
297
- console.log(' ✓ Connection reuse working');
298
- console.log(` ℹ️ Pool size change: ${initialSize} → ${finalSize}`);
299
- // Cleanup
300
- for (const client of clients) {
301
- await client.disconnect();
302
- }
303
- });
304
- });