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