@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,350 +0,0 @@
1
- import { after, before, describe, it } from "node:test";
2
- import assert from "node:assert";
3
- import { cleanupServers, findAvailablePort, HOST, launchServer, TransportMode, } from "./utils/server-launcher.js";
4
- import { findMockServerPort, MockGitLabServer } from "./utils/mock-gitlab-server.js";
5
- const VALID_TOKEN = "test-token-sse-stream-12345";
6
- async function postJsonRpc(url, body, headers = {}) {
7
- const response = await fetch(url, {
8
- method: "POST",
9
- headers: {
10
- "Content-Type": "application/json",
11
- Accept: "application/json, text/event-stream",
12
- ...headers,
13
- },
14
- body: JSON.stringify(body),
15
- });
16
- const sessionId = response.headers.get("mcp-session-id");
17
- const text = await response.text();
18
- const contentType = response.headers.get("content-type") ?? "";
19
- if (contentType.includes("text/event-stream")) {
20
- const dataLines = text
21
- .split("\n")
22
- .filter(line => line.startsWith("data: "))
23
- .map(line => line.slice(6));
24
- const lastLine = dataLines.at(-1);
25
- const parsedData = lastLine && lastLine.length > 0 ? JSON.parse(lastLine) : null;
26
- return {
27
- status: response.status,
28
- data: parsedData,
29
- sessionId,
30
- text,
31
- };
32
- }
33
- const parsedData = text.length > 0 ? JSON.parse(text) : null;
34
- return {
35
- status: response.status,
36
- data: parsedData,
37
- sessionId,
38
- text,
39
- };
40
- }
41
- describe("When Streamable HTTP server is running", () => {
42
- let mockGitLab = null;
43
- let server = null;
44
- let mcpUrl = "";
45
- before(async () => {
46
- const mockPort = await findMockServerPort();
47
- mockGitLab = new MockGitLabServer({
48
- port: mockPort,
49
- validTokens: [VALID_TOKEN],
50
- });
51
- await mockGitLab.start();
52
- const port = await findAvailablePort(3720);
53
- server = await launchServer({
54
- mode: TransportMode.STREAMABLE_HTTP,
55
- port,
56
- timeout: 10_000,
57
- env: {
58
- STREAMABLE_HTTP: "true",
59
- REMOTE_AUTHORIZATION: "true",
60
- GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY: "true",
61
- GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
62
- GITLAB_TOOLSETS: "merge_requests",
63
- },
64
- });
65
- mcpUrl = `http://${HOST}:${port}/mcp`;
66
- });
67
- after(async () => {
68
- if (server) {
69
- await cleanupServers([server]);
70
- server = null;
71
- }
72
- if (mockGitLab) {
73
- await mockGitLab.stop();
74
- mockGitLab = null;
75
- }
76
- });
77
- describe("with initialize handshake", () => {
78
- it("should advertise tools.listChanged capability in server capabilities", async () => {
79
- // Given: initialize request body
80
- const initBody = {
81
- jsonrpc: "2.0",
82
- id: 1,
83
- method: "initialize",
84
- params: {
85
- protocolVersion: "2025-03-26",
86
- capabilities: {},
87
- clientInfo: { name: "test-client", version: "1.0.0" },
88
- },
89
- };
90
- // When: client sends initialize request to /mcp
91
- const response = await postJsonRpc(mcpUrl, initBody);
92
- // Then: response should be 200 and include tools.listChanged: true
93
- assert.strictEqual(response.status, 200, response.text);
94
- assert.ok(response.sessionId, "initialize should return Mcp-Session-Id");
95
- const result = response.data?.result;
96
- assert.ok(typeof result === "object" && result !== null, "Result should be an object");
97
- assert.ok(result.capabilities?.tools, "capabilities.tools should be defined");
98
- assert.strictEqual(result.capabilities.tools.listChanged, true, "tools.listChanged capability must be true");
99
- });
100
- });
101
- describe("with GET requests on /mcp", () => {
102
- it("should return 406 when Accept header lacks text/event-stream", async () => {
103
- // Given: GET request without Accept: text/event-stream
104
- const response = await fetch(mcpUrl, {
105
- method: "GET",
106
- headers: {
107
- Accept: "application/json",
108
- },
109
- });
110
- // When: server evaluates the request
111
- const body = await response.json();
112
- // Then: server should respond with 406 Not Acceptable
113
- assert.strictEqual(response.status, 406);
114
- assert.strictEqual(body?.error, "Not Acceptable");
115
- const allowHeader = response.headers.get("allow");
116
- assert.ok(allowHeader?.includes("GET"), "Allow header should contain GET");
117
- assert.ok(allowHeader?.includes("POST"), "Allow header should contain POST");
118
- assert.ok(allowHeader?.includes("DELETE"), "Allow header should contain DELETE");
119
- });
120
- it("should return 400 when Mcp-Session-Id header is missing", async () => {
121
- // Given: GET request with Accept: text/event-stream but without Mcp-Session-Id
122
- const response = await fetch(mcpUrl, {
123
- method: "GET",
124
- headers: {
125
- Accept: "text/event-stream",
126
- },
127
- });
128
- // When: server evaluates the request
129
- const body = await response.json();
130
- // Then: server should respond with 400 Bad Request
131
- assert.strictEqual(response.status, 400);
132
- assert.strictEqual(body?.error, "Bad Request");
133
- });
134
- it("should return 404 when Mcp-Session-Id does not match any active session", async () => {
135
- // Given: GET request with unknown session ID
136
- const response = await fetch(mcpUrl, {
137
- method: "GET",
138
- headers: {
139
- Accept: "text/event-stream",
140
- "Mcp-Session-Id": "non-existent-session-id-12345",
141
- },
142
- });
143
- // When: server evaluates the request
144
- const body = await response.json();
145
- // Then: server should respond with 404 Session not found
146
- assert.strictEqual(response.status, 404);
147
- assert.strictEqual(body?.error, "Session not found");
148
- });
149
- it("should establish SSE stream when valid Mcp-Session-Id and Accept header are provided", async () => {
150
- // Given: an initialized session
151
- const initBody = {
152
- jsonrpc: "2.0",
153
- id: 10,
154
- method: "initialize",
155
- params: {
156
- protocolVersion: "2025-03-26",
157
- capabilities: {},
158
- clientInfo: { name: "sse-stream-client", version: "1.0.0" },
159
- },
160
- };
161
- const initResponse = await postJsonRpc(mcpUrl, initBody);
162
- const sessionId = initResponse.sessionId;
163
- assert.ok(sessionId, "Session ID should be present");
164
- // When: client connects with GET /mcp and Accept: text/event-stream
165
- const abortController = new AbortController();
166
- const sseResponse = await fetch(mcpUrl, {
167
- method: "GET",
168
- headers: {
169
- Accept: "text/event-stream",
170
- "Mcp-Session-Id": sessionId,
171
- },
172
- signal: abortController.signal,
173
- });
174
- // Then: response should be 200 with Content-Type text/event-stream
175
- assert.strictEqual(sseResponse.status, 200);
176
- const contentType = sseResponse.headers.get("content-type") ?? "";
177
- assert.ok(contentType.includes("text/event-stream"), "Content-Type must be text/event-stream");
178
- abortController.abort();
179
- });
180
- it("should receive list_changed notification over SSE stream when discover_tools runs", async () => {
181
- // Given: an authenticated session connected to GET /mcp SSE stream
182
- const initBody = {
183
- jsonrpc: "2.0",
184
- id: 20,
185
- method: "initialize",
186
- params: {
187
- protocolVersion: "2025-03-26",
188
- capabilities: {},
189
- clientInfo: { name: "sse-notify-client", version: "1.0.0" },
190
- },
191
- };
192
- const initResponse = await postJsonRpc(mcpUrl, initBody, {
193
- "Private-Token": VALID_TOKEN,
194
- });
195
- const sessionId = initResponse.sessionId;
196
- assert.ok(sessionId, "Session ID should be present");
197
- // Initialized notification
198
- await postJsonRpc(mcpUrl, {
199
- jsonrpc: "2.0",
200
- method: "notifications/initialized",
201
- }, {
202
- "mcp-session-id": sessionId,
203
- "Private-Token": VALID_TOKEN,
204
- });
205
- const abortController = new AbortController();
206
- const sseResponse = await fetch(mcpUrl, {
207
- method: "GET",
208
- headers: {
209
- Accept: "text/event-stream",
210
- "Mcp-Session-Id": sessionId,
211
- "Private-Token": VALID_TOKEN,
212
- },
213
- signal: abortController.signal,
214
- });
215
- assert.strictEqual(sseResponse.status, 200);
216
- const streamReader = sseResponse.body?.getReader();
217
- assert.ok(streamReader, "SSE response body reader should exist");
218
- const decoder = new TextDecoder();
219
- let receivedEvents = "";
220
- // Background listener to collect SSE chunks
221
- const readPromise = (async () => {
222
- try {
223
- while (true) {
224
- const { done, value } = await streamReader.read();
225
- if (done)
226
- break;
227
- receivedEvents += decoder.decode(value, { stream: true });
228
- if (receivedEvents.includes("notifications/tools/list_changed")) {
229
- break;
230
- }
231
- }
232
- }
233
- catch {
234
- // Stream closed or aborted
235
- }
236
- })();
237
- // When: client calls discover_tools to activate pipelines category
238
- const discoverResponse = await postJsonRpc(mcpUrl, {
239
- jsonrpc: "2.0",
240
- id: 21,
241
- method: "tools/call",
242
- params: {
243
- name: "discover_tools",
244
- arguments: {
245
- category: "pipelines",
246
- },
247
- },
248
- }, {
249
- "mcp-session-id": sessionId,
250
- "Private-Token": VALID_TOKEN,
251
- });
252
- // Then: discover_tools succeeds and list_changed notification arrives on SSE stream
253
- assert.strictEqual(discoverResponse.status, 200, discoverResponse.text);
254
- let timeoutHandle;
255
- try {
256
- await Promise.race([
257
- readPromise,
258
- new Promise(resolve => {
259
- timeoutHandle = setTimeout(resolve, 3000);
260
- }),
261
- ]);
262
- }
263
- finally {
264
- if (timeoutHandle)
265
- clearTimeout(timeoutHandle);
266
- }
267
- abortController.abort();
268
- assert.ok(receivedEvents.includes("notifications/tools/list_changed"), `SSE stream should receive notifications/tools/list_changed. Received: ${receivedEvents}`);
269
- });
270
- });
271
- describe("with unsupported HTTP methods", () => {
272
- it("should return 405 Method Not Allowed for PUT requests", async () => {
273
- // Given: PUT request to /mcp
274
- const response = await fetch(mcpUrl, { method: "PUT" });
275
- // When: server evaluates the method
276
- const body = await response.json();
277
- // Then: server should respond with 405 Method Not Allowed
278
- assert.strictEqual(response.status, 405);
279
- assert.strictEqual(body?.error, "Method Not Allowed");
280
- const allowHeader = response.headers.get("allow");
281
- assert.ok(allowHeader?.includes("GET"), "Allow header must contain GET");
282
- assert.ok(allowHeader?.includes("POST"), "Allow header must contain POST");
283
- assert.ok(allowHeader?.includes("DELETE"), "Allow header must contain DELETE");
284
- });
285
- });
286
- });
287
- describe("When Streamable HTTP GET SSE is open", { timeout: 20_000 }, () => {
288
- describe("with a short session timeout and no POST traffic", () => {
289
- it("should keep the session alive for the life of the SSE stream", async () => {
290
- const mockPort = await findMockServerPort();
291
- const mockGitLab = new MockGitLabServer({
292
- port: mockPort,
293
- validTokens: [VALID_TOKEN],
294
- });
295
- await mockGitLab.start();
296
- const port = await findAvailablePort(3740);
297
- const timeoutServer = await launchServer({
298
- mode: TransportMode.STREAMABLE_HTTP,
299
- port,
300
- timeout: 10_000,
301
- env: {
302
- STREAMABLE_HTTP: "true",
303
- REMOTE_AUTHORIZATION: "true",
304
- GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
305
- SESSION_TIMEOUT_SECONDS: "1",
306
- },
307
- });
308
- const timeoutMcpUrl = `http://${HOST}:${port}/mcp`;
309
- try {
310
- // Given: an authenticated session with an open GET SSE stream
311
- const initResponse = await postJsonRpc(timeoutMcpUrl, {
312
- jsonrpc: "2.0",
313
- id: 1,
314
- method: "initialize",
315
- params: {
316
- protocolVersion: "2025-03-26",
317
- capabilities: {},
318
- clientInfo: { name: "sse-keepalive-client", version: "1.0.0" },
319
- },
320
- }, { "Private-Token": VALID_TOKEN });
321
- const sessionId = initResponse.sessionId;
322
- assert.ok(sessionId, "Session ID should be present");
323
- const abortController = new AbortController();
324
- const sseResponse = await fetch(timeoutMcpUrl, {
325
- method: "GET",
326
- headers: {
327
- Accept: "text/event-stream",
328
- "Mcp-Session-Id": sessionId,
329
- "Private-Token": VALID_TOKEN,
330
- },
331
- signal: abortController.signal,
332
- });
333
- assert.strictEqual(sseResponse.status, 200);
334
- // When: more than SESSION_TIMEOUT_SECONDS elapses with no POST
335
- await new Promise(resolve => setTimeout(resolve, 2_000));
336
- const listResponse = await postJsonRpc(timeoutMcpUrl, { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }, {
337
- "mcp-session-id": sessionId,
338
- "Private-Token": VALID_TOKEN,
339
- });
340
- // Then: the session is still alive
341
- assert.strictEqual(listResponse.status, 200, listResponse.text);
342
- abortController.abort();
343
- }
344
- finally {
345
- await cleanupServers([timeoutServer]);
346
- await mockGitLab.stop();
347
- }
348
- });
349
- });
350
- });
@@ -1,157 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { spawn } from "node:child_process";
3
- import { once } from "node:events";
4
- import { afterEach, describe, test } from "node:test";
5
- import * as path from "node:path";
6
- import { findAvailablePort } from "./utils/server-launcher.js";
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
- const HOST = process.env.HOST || "127.0.0.1";
9
- const SERVER_PATH = path.resolve(process.cwd(), "build/index.js");
10
- const running = new Set();
11
- function startServer(env, port) {
12
- const child = spawn("node", [SERVER_PATH], {
13
- env: {
14
- ...process.env,
15
- GITLAB_API_URL: "https://gitlab.example.com",
16
- HOST,
17
- PORT: String(port),
18
- STREAMABLE_HTTP: "true",
19
- REMOTE_AUTHORIZATION: "false",
20
- GITLAB_MCP_OAUTH: "false",
21
- GITLAB_USE_OAUTH: "false",
22
- GITLAB_PERSONAL_ACCESS_TOKEN: "",
23
- GITLAB_JOB_TOKEN: "",
24
- GITLAB_AUTH_COOKIE_PATH: "",
25
- MCP_SERVER_URL: "",
26
- GITLAB_OAUTH_APP_ID: "",
27
- ...env,
28
- },
29
- stdio: ["ignore", "pipe", "pipe"],
30
- });
31
- running.add(child);
32
- child.once("exit", () => running.delete(child));
33
- return child;
34
- }
35
- async function waitForExit(child, timeoutMs = 5000) {
36
- let output = "";
37
- child.stdout?.on("data", chunk => {
38
- output += chunk.toString();
39
- });
40
- child.stderr?.on("data", chunk => {
41
- output += chunk.toString();
42
- });
43
- let timeoutHandle;
44
- const timeout = new Promise((_, reject) => {
45
- timeoutHandle = setTimeout(() => reject(new Error(`server did not exit within ${timeoutMs}ms`)), timeoutMs);
46
- });
47
- try {
48
- const [code] = (await Promise.race([once(child, "exit"), timeout]));
49
- return { code, output };
50
- }
51
- finally {
52
- clearTimeout(timeoutHandle);
53
- }
54
- }
55
- async function waitForHealth(port, timeoutMs = 5000) {
56
- const deadline = Date.now() + timeoutMs;
57
- let lastError;
58
- while (Date.now() < deadline) {
59
- try {
60
- const response = await fetch(`http://${HOST}:${port}/health`);
61
- if (response.ok)
62
- return;
63
- }
64
- catch (error) {
65
- lastError = error;
66
- }
67
- await new Promise(resolve => setTimeout(resolve, 100));
68
- }
69
- throw new Error(`server did not become healthy: ${String(lastError)}`);
70
- }
71
- async function postMcpWithoutAuth(port) {
72
- return postMcp(port);
73
- }
74
- async function postMcp(port, token) {
75
- return fetch(`http://${HOST}:${port}/mcp`, {
76
- method: "POST",
77
- headers: {
78
- "Content-Type": "application/json",
79
- Accept: "application/json, text/event-stream",
80
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
81
- },
82
- body: JSON.stringify({
83
- jsonrpc: "2.0",
84
- id: 1,
85
- method: "initialize",
86
- params: {
87
- protocolVersion: "2025-03-26",
88
- capabilities: {},
89
- clientInfo: { name: "static-token-auth-test", version: "1.0.0" },
90
- },
91
- }),
92
- });
93
- }
94
- afterEach(() => {
95
- for (const child of running) {
96
- if (!child.killed)
97
- child.kill("SIGTERM");
98
- }
99
- running.clear();
100
- });
101
- describe("Streamable HTTP static server token auth", () => {
102
- test("refuses startup with PAT and no MCP-layer auth", async () => {
103
- const port = await findAvailablePort(4300);
104
- const child = startServer({ GITLAB_PERSONAL_ACCESS_TOKEN: "glpat_test" }, port);
105
- const { code, output } = await waitForExit(child);
106
- assert.notEqual(code, 0);
107
- assert.match(output, new RegExp(ERROR_MESSAGE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
108
- });
109
- test("refuses startup with JOB-TOKEN and no MCP-layer auth", async () => {
110
- const port = await findAvailablePort(4310);
111
- const child = startServer({ GITLAB_JOB_TOKEN: "job_test" }, port);
112
- const { code, output } = await waitForExit(child);
113
- assert.notEqual(code, 0);
114
- assert.match(output, new RegExp(ERROR_MESSAGE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
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
- });
123
- test("allows startup with PAT when REMOTE_AUTHORIZATION is enabled", async () => {
124
- const port = await findAvailablePort(4320);
125
- startServer({
126
- GITLAB_PERSONAL_ACCESS_TOKEN: "glpat_test",
127
- REMOTE_AUTHORIZATION: "true",
128
- }, port);
129
- await waitForHealth(port);
130
- const initResponse = await postMcpWithoutAuth(port);
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);
144
- });
145
- test("allows startup with PAT when GITLAB_MCP_OAUTH is enabled", async () => {
146
- const port = await findAvailablePort(4330);
147
- startServer({
148
- GITLAB_PERSONAL_ACCESS_TOKEN: "glpat_test",
149
- GITLAB_MCP_OAUTH: "true",
150
- MCP_SERVER_URL: `http://${HOST}:${port}`,
151
- GITLAB_OAUTH_APP_ID: "oauth-app-id",
152
- }, port);
153
- await waitForHealth(port);
154
- const response = await postMcpWithoutAuth(port);
155
- assert.equal(response.status, 401);
156
- });
157
- });