@zereight/mcp-gitlab 2.1.70 → 2.2.1

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 (158) hide show
  1. package/README.ko.md +9 -5
  2. package/README.md +15 -11
  3. package/README.pt-BR.md +9 -5
  4. package/README.zh-CN.md +9 -5
  5. package/build/config.js +12 -4
  6. package/build/deprecated-env.js +63 -0
  7. package/build/index.js +430 -200
  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 +232 -63
  16. package/build/tools/tool-descriptions.js +3 -3
  17. package/build/tools/tool-profile.js +66 -0
  18. package/build/utils/compact-tool-allowlist.js +14 -0
  19. package/build/utils/logger.js +34 -12
  20. package/package.json +3 -3
  21. package/build/scripts/check-skill-sync.js +0 -143
  22. package/build/scripts/generate-server-card.js +0 -39
  23. package/build/scripts/generate-tool-coverage.js +0 -58
  24. package/build/scripts/generate-tool-docs.js +0 -430
  25. package/build/scripts/tool-coverage/coverage.js +0 -341
  26. package/build/scripts/tool-coverage/coverage.test.js +0 -142
  27. package/build/test/callback-proxy-tests.js +0 -321
  28. package/build/test/cli/args.test.js +0 -175
  29. package/build/test/cli/boolean-flags.test.js +0 -106
  30. package/build/test/cli/compact-mcp-result.test.js +0 -93
  31. package/build/test/cli/credentials.test.js +0 -153
  32. package/build/test/cli/exposure.test.js +0 -104
  33. package/build/test/cli/load-config-snapshot.js +0 -5
  34. package/build/test/cli/output.test.js +0 -74
  35. package/build/test/cli/replay-command.test.js +0 -37
  36. package/build/test/cli/router.test.js +0 -183
  37. package/build/test/client-pool-test.js +0 -109
  38. package/build/test/clients/client.js +0 -34
  39. package/build/test/clients/custom-header-client.js +0 -122
  40. package/build/test/clients/sse-client.js +0 -95
  41. package/build/test/clients/stdio-client.js +0 -113
  42. package/build/test/clients/streamable-http-client.js +0 -95
  43. package/build/test/config-allowed-groups.test.js +0 -97
  44. package/build/test/create-commit-actions.test.js +0 -102
  45. package/build/test/draft-note-schema.test.js +0 -44
  46. package/build/test/dynamic-api-url-allowlist.test.js +0 -121
  47. package/build/test/dynamic-api-url-test.js +0 -304
  48. package/build/test/dynamic-routing-tests.js +0 -495
  49. package/build/test/get-merge-request-discussion-schema.test.js +0 -30
  50. package/build/test/gitlab-api-tool-errors.test.js +0 -112
  51. package/build/test/gitlab-artifact-entry-schema.test.js +0 -44
  52. package/build/test/gitlab-diff-schema.test.js +0 -36
  53. package/build/test/gitlab-pipeline-schedule-schema.test.js +0 -32
  54. package/build/test/gitlab-tag-schema.test.js +0 -51
  55. package/build/test/group-milestone-schema.test.js +0 -45
  56. package/build/test/mcp-oauth-tests.js +0 -905
  57. package/build/test/mcp-server-name.test.js +0 -87
  58. package/build/test/merge-merge-request-schema.test.js +0 -75
  59. package/build/test/multi-server-test.js +0 -190
  60. package/build/test/no-proxy-integration-test.js +0 -183
  61. package/build/test/no-proxy-test.js +0 -149
  62. package/build/test/nullable-gitlab-response-fields.test.js +0 -37
  63. package/build/test/nullish-tool-arguments-schema.test.js +0 -118
  64. package/build/test/oauth-device-flow-tests.js +0 -450
  65. package/build/test/oauth-download-url.test.js +0 -157
  66. package/build/test/oauth-startup.test.js +0 -204
  67. package/build/test/oauth-tests.js +0 -428
  68. package/build/test/path-segment-encoding.test.js +0 -98
  69. package/build/test/remote-auth-simple-test.js +0 -277
  70. package/build/test/remote-auth-tests.js +0 -314
  71. package/build/test/response-masking.test.js +0 -445
  72. package/build/test/schema-tests.js +0 -1679
  73. package/build/test/server/metrics.test.js +0 -47
  74. package/build/test/server/request-helpers.test.js +0 -48
  75. package/build/test/server/version.test.js +0 -13
  76. package/build/test/sse-auth-guard.test.js +0 -96
  77. package/build/test/sse-session-limits.test.js +0 -273
  78. package/build/test/stateless/callback-proxy.test.js +0 -510
  79. package/build/test/stateless/client-id.test.js +0 -177
  80. package/build/test/stateless/codec.test.js +0 -328
  81. package/build/test/stateless/config-ttl.test.js +0 -149
  82. package/build/test/stateless/consumed-proxy-code-cache.test.js +0 -150
  83. package/build/test/stateless/session-id-integration.test.js +0 -675
  84. package/build/test/stateless/session-id.test.js +0 -157
  85. package/build/test/streamable-http-concurrent-session.test.js +0 -171
  86. package/build/test/streamable-http-dns-rebinding.test.js +0 -185
  87. package/build/test/streamable-http-sse-stream.test.js +0 -350
  88. package/build/test/streamable-http-static-token-auth.test.js +0 -157
  89. package/build/test/streamable-http-unauthenticated-discovery.test.js +0 -207
  90. package/build/test/test-all-transport-server.js +0 -255
  91. package/build/test/test-auth-retry.js +0 -195
  92. package/build/test/test-blank-filters.js +0 -156
  93. package/build/test/test-ci-catalog.js +0 -177
  94. package/build/test/test-ci-lint.js +0 -191
  95. package/build/test/test-ci-variables.js +0 -309
  96. package/build/test/test-create-repository.js +0 -120
  97. package/build/test/test-dependency-proxy.js +0 -195
  98. package/build/test/test-deployment-tools.js +0 -463
  99. package/build/test/test-download-attachment.js +0 -190
  100. package/build/test/test-dynamic-project-scope.js +0 -524
  101. package/build/test/test-empty-toolsets.js +0 -535
  102. package/build/test/test-get-file-blame.js +0 -145
  103. package/build/test/test-get-file-contents.js +0 -128
  104. package/build/test/test-geteffectiveprojectid.js +0 -804
  105. package/build/test/test-health-check.js +0 -115
  106. package/build/test/test-issue-description-patch.js +0 -256
  107. package/build/test/test-job-artifacts.js +0 -275
  108. package/build/test/test-json-schema.js +0 -166
  109. package/build/test/test-list-group-members.js +0 -150
  110. package/build/test/test-list-group-merge-requests.js +0 -165
  111. package/build/test/test-list-issues.js +0 -170
  112. package/build/test/test-list-merge-requests.js +0 -201
  113. package/build/test/test-list-milestones.js +0 -145
  114. package/build/test/test-list-project-members.js +0 -132
  115. package/build/test/test-merge-request-approval-state-tools.js +0 -213
  116. package/build/test/test-merge-request-approvals.js +0 -187
  117. package/build/test/test-merge-request-pipelines.js +0 -106
  118. package/build/test/test-mr-diffs-filter.js +0 -132
  119. package/build/test/test-mr-file-diffs.js +0 -251
  120. package/build/test/test-oauth-proxy-rate-limit.js +0 -133
  121. package/build/test/test-orbit.js +0 -155
  122. package/build/test/test-permission-mode.js +0 -424
  123. package/build/test/test-pipeline-schedules.js +0 -484
  124. package/build/test/test-protected-branches.js +0 -174
  125. package/build/test/test-remote-downloads.js +0 -510
  126. package/build/test/test-search-code.js +0 -275
  127. package/build/test/test-snippets.js +0 -735
  128. package/build/test/test-tags.js +0 -220
  129. package/build/test/test-todos.js +0 -195
  130. package/build/test/test-token-optimizations.js +0 -691
  131. package/build/test/test-toolset-filtering.js +0 -499
  132. package/build/test/test-update-issue-slim.js +0 -141
  133. package/build/test/test-update-project.js +0 -112
  134. package/build/test/test-upload-markdown.js +0 -206
  135. package/build/test/test-vulnerabilities.js +0 -399
  136. package/build/test/test-webhooks.js +0 -218
  137. package/build/test/tool-description-quality.test.js +0 -50
  138. package/build/test/utils/bulk-publish-options.test.js +0 -32
  139. package/build/test/utils/download-token.test.js +0 -35
  140. package/build/test/utils/forwarded-public-base-url.test.js +0 -46
  141. package/build/test/utils/gitlab-instance-version.test.js +0 -32
  142. package/build/test/utils/gitlab-version-gate.test.js +0 -74
  143. package/build/test/utils/graphql-query.test.js +0 -168
  144. package/build/test/utils/helpers-path.test.js +0 -104
  145. package/build/test/utils/jmespath-tool-result.test.js +0 -91
  146. package/build/test/utils/masking.test.js +0 -384
  147. package/build/test/utils/merge-request-position.test.js +0 -57
  148. package/build/test/utils/mock-gitlab-server.js +0 -614
  149. package/build/test/utils/proxy-client-ip.test.js +0 -28
  150. package/build/test/utils/redact-sensitive.test.js +0 -32
  151. package/build/test/utils/safe-redirect-fetch.test.js +0 -463
  152. package/build/test/utils/server-launcher.js +0 -231
  153. package/build/test/utils/tool-args.test.js +0 -210
  154. package/build/test/utils/url.test.js +0 -141
  155. package/build/test/utils/version-check.test.js +0 -239
  156. package/build/test/utils/wiki-title.test.js +0 -21
  157. package/build/test-note.js +0 -54
  158. 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
- });