@zereight/mcp-gitlab 2.1.28 → 2.1.30

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 (72) hide show
  1. package/README.ko.md +82 -28
  2. package/README.md +56 -23
  3. package/README.zh-CN.md +82 -28
  4. package/build/config.js +13 -0
  5. package/build/downloads/proxy.js +199 -0
  6. package/build/index.js +564 -482
  7. package/build/oauth-proxy.js +18 -4
  8. package/build/schemas.js +47 -9
  9. package/build/scripts/check-skill-sync.js +137 -0
  10. package/build/scripts/generate-tool-docs.js +7 -2
  11. package/build/server/metrics.js +84 -0
  12. package/build/server/transport-mode.js +25 -0
  13. package/build/test/callback-proxy-tests.js +1 -1
  14. package/build/test/client-pool-test.js +1 -1
  15. package/build/test/dynamic-api-url-allowlist.test.js +2 -2
  16. package/build/test/dynamic-api-url-test.js +4 -4
  17. package/build/test/dynamic-routing-tests.js +4 -4
  18. package/build/test/mcp-oauth-tests.js +17 -4
  19. package/build/test/multi-server-test.js +3 -3
  20. package/build/test/no-proxy-integration-test.js +1 -1
  21. package/build/test/path-segment-encoding.test.js +27 -0
  22. package/build/test/remote-auth-simple-test.js +226 -199
  23. package/build/test/server/metrics.test.js +47 -0
  24. package/build/test/sse-auth-guard.test.js +2 -2
  25. package/build/test/stateless/callback-proxy.test.js +8 -0
  26. package/build/test/stateless/session-id-integration.test.js +3 -3
  27. package/build/test/streamable-http-concurrent-session.test.js +1 -1
  28. package/build/test/streamable-http-dns-rebinding.test.js +185 -0
  29. package/build/test/streamable-http-static-token-auth.test.js +25 -22
  30. package/build/test/streamable-http-unauthenticated-discovery.test.js +28 -15
  31. package/build/test/test-ci-catalog.js +1 -1
  32. package/build/test/test-ci-lint.js +1 -1
  33. package/build/test/test-ci-variables.js +8 -5
  34. package/build/test/test-dependency-proxy.js +11 -7
  35. package/build/test/test-deployment-tools.js +16 -2
  36. package/build/test/test-download-attachment.js +1 -1
  37. package/build/test/test-get-file-blame.js +1 -1
  38. package/build/test/test-geteffectiveprojectid.js +7 -7
  39. package/build/test/test-issue-description-patch.js +1 -1
  40. package/build/test/test-job-artifacts.js +1 -1
  41. package/build/test/test-list-issues.js +1 -1
  42. package/build/test/test-list-merge-requests.js +1 -1
  43. package/build/test/test-list-project-members.js +1 -1
  44. package/build/test/test-merge-request-approval-state-tools.js +1 -1
  45. package/build/test/test-merge-request-pipelines.js +1 -1
  46. package/build/test/test-mr-diffs-filter.js +1 -1
  47. package/build/test/test-mr-file-diffs.js +2 -2
  48. package/build/test/test-oauth-proxy-rate-limit.js +1 -1
  49. package/build/test/test-permission-mode.js +215 -0
  50. package/build/test/test-protected-branches.js +1 -1
  51. package/build/test/test-remote-downloads.js +2 -2
  52. package/build/test/test-search-code.js +1 -1
  53. package/build/test/test-tags.js +1 -1
  54. package/build/test/test-todos.js +1 -1
  55. package/build/test/test-token-optimizations.js +3 -3
  56. package/build/test/test-toolset-filtering.js +1 -1
  57. package/build/test/test-update-issue-slim.js +141 -0
  58. package/build/test/test-upload-markdown.js +1 -1
  59. package/build/test/utils/download-token.test.js +35 -0
  60. package/build/test/utils/forwarded-public-base-url.test.js +9 -1
  61. package/build/test/utils/graphql-query.test.js +64 -1
  62. package/build/test/utils/mock-gitlab-server.js +24 -31
  63. package/build/test/utils/redact-sensitive.test.js +32 -0
  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 +30 -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/redact-sensitive.js +21 -0
  71. package/build/utils/version-check.js +40 -0
  72. package/package.json +5 -4
@@ -0,0 +1,185 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawn } from "node:child_process";
3
+ import http from "node:http";
4
+ import { afterEach, describe, test } from "node:test";
5
+ import * as path from "node:path";
6
+ import { findAvailablePort } from "./utils/server-launcher.js";
7
+ import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js";
8
+ const HOST = process.env.HOST || "127.0.0.1";
9
+ const SERVER_PATH = path.resolve(process.cwd(), "build/index.js");
10
+ const TEST_TOKEN = "test-token-123456789012345";
11
+ const running = new Set();
12
+ const mockGitLabServers = [];
13
+ function hostWithPort(host, port) {
14
+ const normalized = host.trim();
15
+ if (normalized.includes(":") && !normalized.startsWith("["))
16
+ return `[${normalized}]:${port}`;
17
+ return `${normalized}:${port}`;
18
+ }
19
+ function startServer(env, port) {
20
+ const child = spawn("node", [SERVER_PATH], {
21
+ env: {
22
+ ...process.env,
23
+ GITLAB_API_URL: "https://gitlab.example.com",
24
+ HOST,
25
+ PORT: String(port),
26
+ STREAMABLE_HTTP: "true",
27
+ REMOTE_AUTHORIZATION: "true",
28
+ GITLAB_MCP_OAUTH: "false",
29
+ GITLAB_USE_OAUTH: "false",
30
+ GITLAB_PERSONAL_ACCESS_TOKEN: "",
31
+ GITLAB_JOB_TOKEN: "",
32
+ GITLAB_AUTH_COOKIE_PATH: "",
33
+ MCP_SERVER_URL: "",
34
+ GITLAB_OAUTH_APP_ID: "",
35
+ ...env,
36
+ },
37
+ stdio: ["ignore", "pipe", "pipe"],
38
+ });
39
+ running.add(child);
40
+ child.once("exit", () => running.delete(child));
41
+ return child;
42
+ }
43
+ async function startAuthenticatedServer(env, port) {
44
+ const mockPort = await findMockServerPort();
45
+ const mockGitLab = new MockGitLabServer({
46
+ port: mockPort,
47
+ validTokens: [TEST_TOKEN],
48
+ });
49
+ await mockGitLab.start();
50
+ mockGitLabServers.push(mockGitLab);
51
+ startServer({
52
+ GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
53
+ ...env,
54
+ }, port);
55
+ }
56
+ async function waitForHealth(port, timeoutMs = 5000) {
57
+ const deadline = Date.now() + timeoutMs;
58
+ let lastError;
59
+ while (Date.now() < deadline) {
60
+ try {
61
+ const response = await fetch(`http://${hostWithPort(HOST, port)}/health`);
62
+ if (response.ok)
63
+ return;
64
+ }
65
+ catch (error) {
66
+ lastError = error;
67
+ }
68
+ await new Promise(resolve => setTimeout(resolve, 100));
69
+ }
70
+ throw new Error(`server did not become healthy: ${String(lastError)}`);
71
+ }
72
+ function postMcp(port, headers, body = JSON.stringify({
73
+ jsonrpc: "2.0",
74
+ id: 1,
75
+ method: "initialize",
76
+ params: {
77
+ protocolVersion: "2025-03-26",
78
+ capabilities: {},
79
+ clientInfo: { name: "dns-rebinding-test", version: "1.0.0" },
80
+ },
81
+ })) {
82
+ return new Promise((resolve, reject) => {
83
+ const req = http.request({
84
+ host: HOST,
85
+ port,
86
+ path: "/mcp",
87
+ method: "POST",
88
+ headers: {
89
+ Host: hostWithPort(HOST, port),
90
+ "Content-Type": "application/json",
91
+ Accept: "application/json, text/event-stream",
92
+ "Private-Token": TEST_TOKEN,
93
+ "Content-Length": Buffer.byteLength(body).toString(),
94
+ ...headers,
95
+ },
96
+ }, res => {
97
+ let responseBody = "";
98
+ res.setEncoding("utf8");
99
+ res.on("data", chunk => {
100
+ responseBody += chunk;
101
+ });
102
+ res.on("end", () => resolve({ status: res.statusCode ?? 0, body: responseBody }));
103
+ });
104
+ req.on("error", reject);
105
+ req.end(body);
106
+ });
107
+ }
108
+ afterEach(async () => {
109
+ for (const child of running) {
110
+ if (!child.killed)
111
+ child.kill("SIGTERM");
112
+ }
113
+ running.clear();
114
+ while (mockGitLabServers.length > 0) {
115
+ const mockGitLab = mockGitLabServers.pop();
116
+ if (mockGitLab) {
117
+ await mockGitLab.stop();
118
+ }
119
+ }
120
+ });
121
+ describe("Streamable HTTP DNS rebinding protection", () => {
122
+ test("rejects forged Host and Origin headers before handling /mcp", async () => {
123
+ const port = await findAvailablePort(4700);
124
+ await startAuthenticatedServer({}, port);
125
+ await waitForHealth(port);
126
+ const validHost = hostWithPort(HOST, port);
127
+ const badJson = await postMcp(port, {
128
+ Host: "attacker.example.test",
129
+ Origin: `http://${validHost}`,
130
+ }, "{");
131
+ assert.equal(badJson.status, 403);
132
+ assert.match(badJson.body, /Host header is not allowed/);
133
+ assert.match(badJson.body, /MCP_ALLOWED_HOSTS/);
134
+ const badHost = await postMcp(port, {
135
+ Host: "attacker.example.test",
136
+ Origin: `http://${validHost}`,
137
+ });
138
+ assert.equal(badHost.status, 403);
139
+ assert.match(badHost.body, /Host header is not allowed/);
140
+ assert.match(badHost.body, /MCP_ALLOWED_HOSTS/);
141
+ const badOrigin = await postMcp(port, {
142
+ Host: validHost,
143
+ Origin: "https://attacker.example.test",
144
+ });
145
+ assert.equal(badOrigin.status, 403);
146
+ assert.match(badOrigin.body, /Origin header is not allowed/);
147
+ assert.match(badOrigin.body, /MCP_ALLOWED_ORIGINS/);
148
+ const ok = await postMcp(port, { Host: validHost });
149
+ assert.equal(ok.status, 200);
150
+ });
151
+ test("allows loopback Host and Origin on any port (Docker port mapping)", async () => {
152
+ const port = await findAvailablePort(4720);
153
+ await startAuthenticatedServer({}, port);
154
+ await waitForHealth(port);
155
+ // e.g. docker run -p 3333:3002 -> client sends Host: localhost:3333
156
+ const remappedHost = await postMcp(port, { Host: `localhost:${port + 1000}` });
157
+ assert.equal(remappedHost.status, 200);
158
+ const remappedIpv4 = await postMcp(port, { Host: `127.0.0.1:${port + 1000}` });
159
+ assert.equal(remappedIpv4.status, 200);
160
+ const remappedOrigin = await postMcp(port, {
161
+ Host: `localhost:${port + 1000}`,
162
+ Origin: `http://localhost:${port + 1000}`,
163
+ });
164
+ assert.equal(remappedOrigin.status, 200);
165
+ const bareLoopback = await postMcp(port, { Host: "localhost" });
166
+ assert.equal(bareLoopback.status, 200);
167
+ const loopbackHostBadOrigin = await postMcp(port, {
168
+ Host: `localhost:${port + 1000}`,
169
+ Origin: "http://attacker.example.test",
170
+ });
171
+ assert.equal(loopbackHostBadOrigin.status, 403);
172
+ assert.match(loopbackHostBadOrigin.body, /Origin header is not allowed/);
173
+ assert.match(loopbackHostBadOrigin.body, /MCP_ALLOWED_ORIGINS/);
174
+ });
175
+ test("allows the configured MCP_SERVER_URL host and origin", async () => {
176
+ const port = await findAvailablePort(4710);
177
+ await startAuthenticatedServer({ MCP_SERVER_URL: "https://mcp.example.test" }, port);
178
+ await waitForHealth(port);
179
+ const ok = await postMcp(port, {
180
+ Host: "mcp.example.test",
181
+ Origin: "https://mcp.example.test",
182
+ });
183
+ assert.equal(ok.status, 200);
184
+ });
185
+ });
@@ -4,7 +4,7 @@ import { once } from "node:events";
4
4
  import { afterEach, describe, test } from "node:test";
5
5
  import * as path from "node:path";
6
6
  import { findAvailablePort } from "./utils/server-launcher.js";
7
- const ERROR_MESSAGE = "STREAMABLE_HTTP=true/--streamable-http with GITLAB_PERSONAL_ACCESS_TOKEN/--token or GITLAB_JOB_TOKEN/--job-token requires REMOTE_AUTHORIZATION=true/--remote-auth=true or GITLAB_MCP_OAUTH=true/--mcp-oauth=true";
7
+ const ERROR_MESSAGE = "STREAMABLE_HTTP=true/--streamable-http with server-side GitLab credentials requires REMOTE_AUTHORIZATION=true/--remote-auth=true, GITLAB_MCP_OAUTH=true/--mcp-oauth=true, or STREAMABLE_HTTP_AUTH_TOKEN/--streamable-http-auth-token";
8
8
  const HOST = process.env.HOST || "127.0.0.1";
9
9
  const SERVER_PATH = path.resolve(process.cwd(), "build/index.js");
10
10
  const running = new Set();
@@ -69,11 +69,15 @@ async function waitForHealth(port, timeoutMs = 5000) {
69
69
  throw new Error(`server did not become healthy: ${String(lastError)}`);
70
70
  }
71
71
  async function postMcpWithoutAuth(port) {
72
+ return postMcp(port);
73
+ }
74
+ async function postMcp(port, token) {
72
75
  return fetch(`http://${HOST}:${port}/mcp`, {
73
76
  method: "POST",
74
77
  headers: {
75
78
  "Content-Type": "application/json",
76
79
  Accept: "application/json, text/event-stream",
80
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
77
81
  },
78
82
  body: JSON.stringify({
79
83
  jsonrpc: "2.0",
@@ -87,22 +91,6 @@ async function postMcpWithoutAuth(port) {
87
91
  }),
88
92
  });
89
93
  }
90
- async function postMcpWithSessionWithoutAuth(port, sessionId) {
91
- return fetch(`http://${HOST}:${port}/mcp`, {
92
- method: "POST",
93
- headers: {
94
- "Content-Type": "application/json",
95
- Accept: "application/json, text/event-stream",
96
- "Mcp-Session-Id": sessionId,
97
- },
98
- body: JSON.stringify({
99
- jsonrpc: "2.0",
100
- id: 2,
101
- method: "tools/list",
102
- params: {},
103
- }),
104
- });
105
- }
106
94
  afterEach(() => {
107
95
  for (const child of running) {
108
96
  if (!child.killed)
@@ -125,6 +113,13 @@ describe("Streamable HTTP static server token auth", () => {
125
113
  assert.notEqual(code, 0);
126
114
  assert.match(output, new RegExp(ERROR_MESSAGE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
127
115
  });
116
+ test("refuses startup with cookie auth and no MCP-layer auth", async () => {
117
+ const port = await findAvailablePort(4315);
118
+ const child = startServer({ GITLAB_AUTH_COOKIE_PATH: "./cookies.txt" }, port);
119
+ const { code, output } = await waitForExit(child);
120
+ assert.notEqual(code, 0);
121
+ assert.match(output, new RegExp(ERROR_MESSAGE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
122
+ });
128
123
  test("allows startup with PAT when REMOTE_AUTHORIZATION is enabled", async () => {
129
124
  const port = await findAvailablePort(4320);
130
125
  startServer({
@@ -133,11 +128,19 @@ describe("Streamable HTTP static server token auth", () => {
133
128
  }, port);
134
129
  await waitForHealth(port);
135
130
  const initResponse = await postMcpWithoutAuth(port);
136
- assert.equal(initResponse.status, 200);
137
- const sessionId = initResponse.headers.get("mcp-session-id");
138
- assert.ok(sessionId);
139
- const followUpResponse = await postMcpWithSessionWithoutAuth(port, sessionId);
140
- assert.equal(followUpResponse.status, 401);
131
+ assert.equal(initResponse.status, 401);
132
+ });
133
+ test("requires configured Streamable HTTP auth token", async () => {
134
+ const port = await findAvailablePort(4325);
135
+ startServer({
136
+ GITLAB_PERSONAL_ACCESS_TOKEN: "glpat_test",
137
+ STREAMABLE_HTTP_AUTH_TOKEN: "mcp_static_secret",
138
+ }, port);
139
+ await waitForHealth(port);
140
+ const unauthenticated = await postMcpWithoutAuth(port);
141
+ assert.equal(unauthenticated.status, 401);
142
+ const authenticated = await postMcp(port, "mcp_static_secret");
143
+ assert.equal(authenticated.status, 200);
141
144
  });
142
145
  test("allows startup with PAT when GITLAB_MCP_OAUTH is enabled", async () => {
143
146
  const port = await findAvailablePort(4330);
@@ -36,24 +36,26 @@ async function rawMcpRequest(url, body, headers = {}) {
36
36
  text,
37
37
  };
38
38
  }
39
+ const initializeBody = {
40
+ jsonrpc: "2.0",
41
+ id: 1,
42
+ method: "initialize",
43
+ params: {
44
+ protocolVersion: "2025-03-26",
45
+ capabilities: {},
46
+ clientInfo: { name: "unauth-discovery-test", version: "1.0.0" },
47
+ },
48
+ };
39
49
  async function initialize(mcpUrl) {
40
- const response = await rawMcpRequest(mcpUrl, {
41
- jsonrpc: "2.0",
42
- id: 1,
43
- method: "initialize",
44
- params: {
45
- protocolVersion: "2025-03-26",
46
- capabilities: {},
47
- clientInfo: { name: "unauth-discovery-test", version: "1.0.0" },
48
- },
49
- });
50
+ const response = await rawMcpRequest(mcpUrl, initializeBody);
50
51
  assert.strictEqual(response.status, 200, response.text);
51
52
  assert.ok(response.sessionId, "initialize should return Mcp-Session-Id");
52
53
  return response.sessionId;
53
54
  }
54
55
  let portOffset = 0;
55
56
  async function launchRemoteAuthServer(extraEnv = {}) {
56
- const port = await findAvailablePort(3470 + portOffset++ * 10);
57
+ // Keep above remote-auth-simple-test timeout suite (3500+) to avoid parallel bind races.
58
+ const port = await findAvailablePort(3600 + portOffset++ * 10);
57
59
  const server = await launchServer({
58
60
  mode: TransportMode.STREAMABLE_HTTP,
59
61
  port,
@@ -73,12 +75,23 @@ describe("Streamable HTTP unauthenticated tool discovery", { timeout: 20_000 },
73
75
  cleanupServers(servers);
74
76
  servers = [];
75
77
  });
76
- test("keeps unauthenticated tools/list blocked by default", async () => {
78
+ test("keeps unauthenticated initialize blocked by default", async () => {
77
79
  const { server, mcpUrl } = await launchRemoteAuthServer();
78
80
  servers.push(server);
79
- const sessionId = await initialize(mcpUrl);
80
- const listResponse = await rawMcpRequest(mcpUrl, { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }, { "mcp-session-id": sessionId });
81
- assert.strictEqual(listResponse.status, 401, listResponse.text);
81
+ const initResponse = await rawMcpRequest(mcpUrl, initializeBody);
82
+ assert.strictEqual(initResponse.status, 401, initResponse.text);
83
+ assert.strictEqual(initResponse.sessionId, null);
84
+ });
85
+ test("rejects invalid auth headers even when unauthenticated discovery is enabled", async () => {
86
+ const { server, mcpUrl } = await launchRemoteAuthServer({
87
+ GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY: "true",
88
+ });
89
+ servers.push(server);
90
+ const initResponse = await rawMcpRequest(mcpUrl, initializeBody, {
91
+ Authorization: "Bearer definitely-not-valid",
92
+ });
93
+ assert.strictEqual(initResponse.status, 401, initResponse.text);
94
+ assert.strictEqual(initResponse.sessionId, null);
82
95
  });
83
96
  test("allows unauthenticated tools/list when explicitly enabled", async () => {
84
97
  const { server, mcpUrl } = await launchRemoteAuthServer({
@@ -56,7 +56,7 @@ describe("CI/CD Catalog tools", () => {
56
56
  GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
57
57
  });
58
58
  before(async () => {
59
- const port = await findMockServerPort(9000);
59
+ const port = await findMockServerPort();
60
60
  mockGitLab = new MockGitLabServer({ port, validTokens: [MOCK_TOKEN] });
61
61
  mockGitLab.addRootHandler("post", "/api/graphql", (req, res) => {
62
62
  if (req.body.query.includes("ListCiCatalogResources")) {
@@ -92,7 +92,7 @@ describe("GitLab CI lint tools", () => {
92
92
  let mockGitLab;
93
93
  let mockGitLabUrl;
94
94
  before(async () => {
95
- const mockPort = await findMockServerPort(9260);
95
+ const mockPort = await findMockServerPort();
96
96
  mockGitLab = new MockGitLabServer({
97
97
  port: mockPort,
98
98
  validTokens: [MOCK_TOKEN],
@@ -77,12 +77,14 @@ async function callTool(toolName, args, env) {
77
77
  }
78
78
  describe("CI/CD variable tools", () => {
79
79
  let mockServer;
80
- let mockPort;
80
+ let mockApiUrl;
81
81
  let baseEnv;
82
82
  let lastReceivedFilterScope;
83
83
  before(async () => {
84
- mockPort = await findMockServerPort();
85
- mockServer = new MockGitLabServer({ port: mockPort, validTokens: [MOCK_TOKEN] });
84
+ mockServer = new MockGitLabServer({
85
+ port: await findMockServerPort(),
86
+ validTokens: [MOCK_TOKEN],
87
+ });
86
88
  // --- Project variable endpoints ---
87
89
  mockServer.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/variables`, (_req, res) => {
88
90
  res.json([MOCK_PROJECT_VARIABLE]);
@@ -124,9 +126,10 @@ describe("CI/CD variable tools", () => {
124
126
  res.status(204).send();
125
127
  });
126
128
  await mockServer.start();
129
+ mockApiUrl = `${mockServer.getUrl()}/api/v4`;
127
130
  baseEnv = {
128
131
  GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
129
- GITLAB_API_URL: `http://localhost:${mockPort}/api/v4`,
132
+ GITLAB_API_URL: mockApiUrl,
130
133
  GITLAB_TOOLSETS: "variables",
131
134
  };
132
135
  });
@@ -245,7 +248,7 @@ describe("CI/CD variable tools", () => {
245
248
  env: {
246
249
  ...process.env,
247
250
  GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
248
- GITLAB_API_URL: `http://localhost:${mockPort}/api/v4`,
251
+ GITLAB_API_URL: mockApiUrl,
249
252
  // No GITLAB_TOOLSETS — default toolsets only
250
253
  },
251
254
  });
@@ -45,11 +45,16 @@ async function callTool(toolName, args, env) {
45
45
  }
46
46
  describe("dependency proxy tools", () => {
47
47
  let mockServer;
48
- let mockPort;
48
+ let mockApiUrl;
49
49
  let baseEnv;
50
50
  before(async () => {
51
- mockPort = await findMockServerPort();
52
- mockServer = new MockGitLabServer({ port: mockPort, validTokens: [MOCK_TOKEN] });
51
+ mockServer = new MockGitLabServer({
52
+ port: await findMockServerPort(),
53
+ validTokens: [MOCK_TOKEN],
54
+ });
55
+ await mockServer.start();
56
+ const mockHost = new URL(mockServer.getUrl()).host;
57
+ mockApiUrl = `${mockServer.getUrl()}/api/v4`;
53
58
  // Mock GraphQL endpoint for get/list operations
54
59
  mockServer.addRootHandler("post", "/api/graphql", (req, res) => {
55
60
  const { query } = req.body;
@@ -63,7 +68,7 @@ describe("dependency proxy tools", () => {
63
68
  dependencyProxySetting: { enabled: true },
64
69
  dependencyProxyBlobCount: 3,
65
70
  dependencyProxyTotalSize: "15728640",
66
- dependencyProxyImagePrefix: `localhost:${mockPort}/my-group/dependency_proxy/containers`,
71
+ dependencyProxyImagePrefix: `${mockHost}/my-group/dependency_proxy/containers`,
67
72
  dependencyProxyImageTtlPolicy: { enabled: true, ttl: 90 },
68
73
  },
69
74
  },
@@ -92,10 +97,9 @@ describe("dependency proxy tools", () => {
92
97
  mockServer.addMockHandler("delete", `/groups/${TEST_GROUP_PATH}/dependency_proxy/cache`, (_req, res) => {
93
98
  res.status(202).send();
94
99
  });
95
- await mockServer.start();
96
100
  baseEnv = {
97
101
  GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
98
- GITLAB_API_URL: `http://localhost:${mockPort}/api/v4`,
102
+ GITLAB_API_URL: mockApiUrl,
99
103
  GITLAB_TOOLSETS: "dependency_proxy",
100
104
  };
101
105
  });
@@ -135,7 +139,7 @@ describe("dependency proxy tools", () => {
135
139
  env: {
136
140
  ...process.env,
137
141
  GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
138
- GITLAB_API_URL: `http://localhost:${mockPort}/api/v4`,
142
+ GITLAB_API_URL: mockApiUrl,
139
143
  // No GITLAB_TOOLSETS — default toolsets only
140
144
  },
141
145
  });
@@ -114,7 +114,7 @@ describe("deployment and environment tools", () => {
114
114
  let mockGitLab;
115
115
  let mockGitLabUrl;
116
116
  before(async () => {
117
- const mockPort = await findMockServerPort(9300);
117
+ const mockPort = await findMockServerPort();
118
118
  mockGitLab = new MockGitLabServer({
119
119
  port: mockPort,
120
120
  validTokens: [MOCK_TOKEN],
@@ -318,11 +318,25 @@ describe("deployment and environment tools", () => {
318
318
  assert.strictEqual(result.name, "stage");
319
319
  assert.strictEqual(result.state, "available");
320
320
  });
321
- test("get_merge_request returns compact deployment summary sorted by created_at desc", async () => {
321
+ test("get_merge_request omits summaries by default", async () => {
322
322
  const result = await callTool("get_merge_request", { project_id: TEST_PROJECT_ID, merge_request_iid: TEST_MERGE_REQUEST_IID }, {
323
323
  GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
324
324
  GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
325
325
  });
326
+ assert.strictEqual(result.iid, TEST_MERGE_REQUEST_IID);
327
+ assert.strictEqual(result.deployment_summary, undefined, "deployment_summary should be omitted by default");
328
+ assert.strictEqual(result.commit_addition_summary, undefined, "commit_addition_summary should be omitted by default");
329
+ assert.strictEqual(result.approval_summary, undefined, "approval_summary should be omitted by default");
330
+ });
331
+ test("get_merge_request returns compact deployment summary sorted by created_at desc", async () => {
332
+ const result = await callTool("get_merge_request", {
333
+ project_id: TEST_PROJECT_ID,
334
+ merge_request_iid: TEST_MERGE_REQUEST_IID,
335
+ include_summaries: true,
336
+ }, {
337
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
338
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
339
+ });
326
340
  assert.ok(result.deployment_summary, "deployment_summary should be present");
327
341
  assert.strictEqual(result.diverged_commits_count, TEST_DIVERGED_COMMITS_COUNT, "diverged_commits_count should be requested and returned by default");
328
342
  assert.strictEqual(result.deployment_summary.lookup_sha, TEST_MERGE_REQUEST_SHA);
@@ -60,7 +60,7 @@ describe('download_attachment', () => {
60
60
  let mockGitLab;
61
61
  let env;
62
62
  before(async () => {
63
- const port = await findMockServerPort(9100);
63
+ const port = await findMockServerPort();
64
64
  mockGitLab = new MockGitLabServer({ port, validTokens: [MOCK_TOKEN] });
65
65
  await mockGitLab.start();
66
66
  env = {
@@ -72,7 +72,7 @@ describe("get_file_blame", () => {
72
72
  let mockGitLabUrl;
73
73
  let lastQuery = {};
74
74
  before(async () => {
75
- const mockPort = await findMockServerPort(9000);
75
+ const mockPort = await findMockServerPort();
76
76
  mockGitLab = new MockGitLabServer({
77
77
  port: mockPort,
78
78
  validTokens: [MOCK_TOKEN],
@@ -28,7 +28,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
28
28
  let client;
29
29
  before(async () => {
30
30
  // Start mock GitLab server
31
- const mockPort = await findMockServerPort(9100);
31
+ const mockPort = await findMockServerPort();
32
32
  mockGitLab = new MockGitLabServer({
33
33
  port: mockPort,
34
34
  validTokens: [MOCK_TOKEN]
@@ -102,7 +102,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
102
102
  let client;
103
103
  before(async () => {
104
104
  // Start mock GitLab server
105
- const mockPort = await findMockServerPort(9200);
105
+ const mockPort = await findMockServerPort();
106
106
  mockGitLab = new MockGitLabServer({
107
107
  port: mockPort,
108
108
  validTokens: [MOCK_TOKEN]
@@ -174,7 +174,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
174
174
  let client;
175
175
  before(async () => {
176
176
  // Start mock GitLab server
177
- const mockPort = await findMockServerPort(9300);
177
+ const mockPort = await findMockServerPort();
178
178
  mockGitLab = new MockGitLabServer({
179
179
  port: mockPort,
180
180
  validTokens: [MOCK_TOKEN]
@@ -255,7 +255,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
255
255
  let servers = [];
256
256
  let client;
257
257
  before(async () => {
258
- const mockPort = await findMockServerPort(9400);
258
+ const mockPort = await findMockServerPort();
259
259
  mockGitLab = new MockGitLabServer({
260
260
  port: mockPort,
261
261
  validTokens: [MOCK_TOKEN]
@@ -353,7 +353,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
353
353
  let servers = [];
354
354
  let client;
355
355
  before(async () => {
356
- const mockPort = await findMockServerPort(9600);
356
+ const mockPort = await findMockServerPort();
357
357
  mockGitLab = new MockGitLabServer({
358
358
  port: mockPort,
359
359
  validTokens: [MOCK_TOKEN]
@@ -451,7 +451,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
451
451
  let servers = [];
452
452
  let client;
453
453
  before(async () => {
454
- const mockPort = await findMockServerPort(9500);
454
+ const mockPort = await findMockServerPort();
455
455
  mockGitLab = new MockGitLabServer({
456
456
  port: mockPort,
457
457
  validTokens: [MOCK_TOKEN]
@@ -514,7 +514,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
514
514
  let servers = [];
515
515
  let client;
516
516
  before(async () => {
517
- const mockPort = await findMockServerPort(9500);
517
+ const mockPort = await findMockServerPort();
518
518
  mockGitLab = new MockGitLabServer({
519
519
  port: mockPort,
520
520
  validTokens: [MOCK_TOKEN],
@@ -137,7 +137,7 @@ describe("update_issue_description_patch MCP tool", () => {
137
137
  return client;
138
138
  }
139
139
  before(async () => {
140
- const mockPort = await findMockServerPort(MOCK_PORT_BASE);
140
+ const mockPort = await findMockServerPort();
141
141
  mockGitLab = new MockGitLabServer({
142
142
  port: mockPort,
143
143
  validTokens: [MOCK_TOKEN],
@@ -69,7 +69,7 @@ describe('job artifacts tools', () => {
69
69
  let mockGitLabUrl;
70
70
  let tmpDir;
71
71
  before(async () => {
72
- const mockPort = await findMockServerPort(9200);
72
+ const mockPort = await findMockServerPort();
73
73
  mockGitLab = new MockGitLabServer({
74
74
  port: mockPort,
75
75
  validTokens: [MOCK_TOKEN],
@@ -65,7 +65,7 @@ describe("list_issues", () => {
65
65
  let mockGitLab;
66
66
  let mockGitLabUrl;
67
67
  before(async () => {
68
- const mockPort = await findMockServerPort(9000);
68
+ const mockPort = await findMockServerPort();
69
69
  mockGitLab = new MockGitLabServer({
70
70
  port: mockPort,
71
71
  validTokens: [MOCK_TOKEN],
@@ -62,7 +62,7 @@ describe('list_merge_requests', () => {
62
62
  let mockGitLab;
63
63
  let mockGitLabUrl;
64
64
  before(async () => {
65
- const mockPort = await findMockServerPort(9000);
65
+ const mockPort = await findMockServerPort();
66
66
  mockGitLab = new MockGitLabServer({
67
67
  port: mockPort,
68
68
  validTokens: [MOCK_TOKEN]
@@ -84,7 +84,7 @@ describe('list_project_members', () => {
84
84
  let directEndpointHit = false;
85
85
  let inheritedEndpointHit = false;
86
86
  before(async () => {
87
- const mockPort = await findMockServerPort(9000);
87
+ const mockPort = await findMockServerPort();
88
88
  mockGitLab = new MockGitLabServer({
89
89
  port: mockPort,
90
90
  validTokens: [MOCK_TOKEN]
@@ -65,7 +65,7 @@ describe("merge request approval state tools", () => {
65
65
  let mockGitLab;
66
66
  let mockGitLabUrl;
67
67
  before(async () => {
68
- const mockPort = await findMockServerPort(9400);
68
+ const mockPort = await findMockServerPort();
69
69
  mockGitLab = new MockGitLabServer({
70
70
  port: mockPort,
71
71
  validTokens: [MOCK_TOKEN],
@@ -55,7 +55,7 @@ describe("list_merge_request_pipelines", () => {
55
55
  let mockGitLabUrl;
56
56
  let lastQuery = {};
57
57
  before(async () => {
58
- const mockPort = await findMockServerPort(9250);
58
+ const mockPort = await findMockServerPort();
59
59
  mockGitLab = new MockGitLabServer({
60
60
  port: mockPort,
61
61
  validTokens: [MOCK_TOKEN],
@@ -62,7 +62,7 @@ describe('get_merge_request_diffs with excluded_file_patterns', () => {
62
62
  let mockGitLab;
63
63
  let mockGitLabUrl;
64
64
  before(async () => {
65
- const mockPort = await findMockServerPort(9100);
65
+ const mockPort = await findMockServerPort();
66
66
  mockGitLab = new MockGitLabServer({
67
67
  port: mockPort,
68
68
  validTokens: [MOCK_TOKEN]