@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,450 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import * as fs from "node:fs";
4
- import * as os from "node:os";
5
- import * as path from "node:path";
6
- import { getPositionalCliCommand } from "../cli-command.js";
7
- import { gitlabOriginFromApiUrl, runAuthCommandAsync } from "../auth-cli.js";
8
- import { runDeviceAuthorizationGrantAsync } from "../oauth-device-flow.js";
9
- import { GitLabOAuth } from "../oauth.js";
10
- const GITLAB_URL = "https://gitlab.example";
11
- const CLIENT_ID = "test-client-id";
12
- const DEVICE_CODE = "device-code-not-for-stdout";
13
- const USER_CODE = "0A44L90H";
14
- const ACCESS_TOKEN = "oauth-device-flow-access-token";
15
- const REFRESH_TOKEN = "refresh-token-device-flow";
16
- function requestUrl(input) {
17
- if (typeof input === "string") {
18
- return input;
19
- }
20
- if (input instanceof URL) {
21
- return input.href;
22
- }
23
- return input.url;
24
- }
25
- function jsonResponse(status, body) {
26
- return new Response(JSON.stringify(body), {
27
- status,
28
- headers: { "Content-Type": "application/json" },
29
- });
30
- }
31
- function createFetchStub(plan, capture) {
32
- const remainingTokens = [...plan.tokens];
33
- return async (input, init) => {
34
- const url = requestUrl(input);
35
- if (url.includes("/oauth/authorize_device")) {
36
- if (typeof init?.body === "string") {
37
- capture?.authorizeBodies.push(init.body);
38
- }
39
- return jsonResponse(plan.authorize.status, plan.authorize.body);
40
- }
41
- if (url.includes("/oauth/token")) {
42
- const next = remainingTokens.shift();
43
- if (!next) {
44
- throw new Error(`Unexpected token poll: ${url}`);
45
- }
46
- return jsonResponse(next.status, next.body);
47
- }
48
- throw new Error(`Unexpected fetch URL: ${url}`);
49
- };
50
- }
51
- function deviceAuthorizationBody(interval = 5) {
52
- return {
53
- device_code: DEVICE_CODE,
54
- user_code: USER_CODE,
55
- verification_uri: `${GITLAB_URL}/oauth/device`,
56
- verification_uri_complete: `${GITLAB_URL}/oauth/device?user_code=${USER_CODE}`,
57
- expires_in: 300,
58
- interval,
59
- };
60
- }
61
- function successTokenBody() {
62
- return {
63
- access_token: ACCESS_TOKEN,
64
- refresh_token: REFRESH_TOKEN,
65
- expires_in: 7200,
66
- token_type: "Bearer",
67
- };
68
- }
69
- function pendingBody() {
70
- return { error: "authorization_pending" };
71
- }
72
- function createTempTokenPath() {
73
- return path.join(os.tmpdir(), `gitlab-mcp-device-flow-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.json`);
74
- }
75
- function createOauthClient(tokenStoragePath) {
76
- return new GitLabOAuth({
77
- clientId: CLIENT_ID,
78
- redirectUri: "http://127.0.0.1:8888/callback",
79
- gitlabUrl: GITLAB_URL,
80
- scopes: ["api"],
81
- tokenStoragePath,
82
- });
83
- }
84
- function createAuthorizeCapture() {
85
- return { authorizeBodies: [] };
86
- }
87
- function capturedAuthorizeScope(bodies) {
88
- if (bodies.length !== 1) {
89
- return null;
90
- }
91
- const [body] = bodies;
92
- if (body === undefined) {
93
- return null;
94
- }
95
- return new URLSearchParams(body).get("scope");
96
- }
97
- async function runAuthCapturingScopeAsync(input) {
98
- const tokenPath = createTempTokenPath();
99
- const capture = createAuthorizeCapture();
100
- const fetchImpl = createFetchStub({
101
- authorize: { status: 200, body: deviceAuthorizationBody() },
102
- tokens: [{ status: 200, body: successTokenBody() }],
103
- }, capture);
104
- try {
105
- await runAuthCommandAsync({
106
- argv: [
107
- "node",
108
- "index.js",
109
- "auth",
110
- `--client-id=${CLIENT_ID}`,
111
- `--api-url=${GITLAB_URL}`,
112
- `--token-path=${tokenPath}`,
113
- ...(input.extraArgv ?? []),
114
- ],
115
- env: input.env ?? {},
116
- stdout: createStdoutBuffer().stdout,
117
- fetchImpl,
118
- sleepAsync: async () => { },
119
- });
120
- return capturedAuthorizeScope(capture.authorizeBodies);
121
- }
122
- finally {
123
- if (fs.existsSync(tokenPath)) {
124
- fs.unlinkSync(tokenPath);
125
- }
126
- }
127
- }
128
- function createStdoutBuffer() {
129
- const chunks = [];
130
- return {
131
- stdout: {
132
- write(chunk) {
133
- chunks.push(chunk);
134
- return true;
135
- },
136
- },
137
- text: () => chunks.join(""),
138
- };
139
- }
140
- function createSleepRecorder() {
141
- const delays = [];
142
- return {
143
- delays,
144
- async sleepAsync(ms) {
145
- delays.push(ms);
146
- },
147
- };
148
- }
149
- function readStoredToken(value) {
150
- if (typeof value !== "object" || value === null) {
151
- throw new Error("token file is not an object");
152
- }
153
- const accessToken = Reflect.get(value, "access_token");
154
- const refreshToken = Reflect.get(value, "refresh_token");
155
- const expiresIn = Reflect.get(value, "expires_in");
156
- const createdAt = Reflect.get(value, "created_at");
157
- const tokenType = Reflect.get(value, "token_type");
158
- if (typeof accessToken !== "string") {
159
- throw new Error("access_token missing");
160
- }
161
- if (typeof createdAt !== "number") {
162
- throw new Error("created_at missing");
163
- }
164
- if (typeof tokenType !== "string") {
165
- throw new Error("token_type missing");
166
- }
167
- return {
168
- access_token: accessToken,
169
- refresh_token: typeof refreshToken === "string" ? refreshToken : undefined,
170
- expires_in: typeof expiresIn === "number" ? expiresIn : undefined,
171
- created_at: createdAt,
172
- token_type: tokenType,
173
- };
174
- }
175
- describe("When reading the CLI command", () => {
176
- describe("with auth as the first positional argument", () => {
177
- it("should return auth", () => {
178
- assert.equal(getPositionalCliCommand(["node", "index.js", "auth"]), "auth");
179
- });
180
- });
181
- describe("with only --token", () => {
182
- it("should return undefined so MCP startup stays unchanged", () => {
183
- assert.equal(getPositionalCliCommand(["node", "index.js", "--token=glpat-xxxxxxxx"]), undefined);
184
- });
185
- });
186
- describe("with flags before auth", () => {
187
- it("should still return auth", () => {
188
- assert.equal(getPositionalCliCommand(["node", "index.js", "--api-url=https://gitlab.com", "auth"]), "auth");
189
- });
190
- });
191
- describe("with tsx index.ts auth", () => {
192
- it("should skip the script path and return auth", () => {
193
- assert.equal(getPositionalCliCommand(["node", "/usr/local/bin/tsx", "index.ts", "auth", "--help"]), "auth");
194
- });
195
- });
196
- describe("with --use-oauth before a curated command", () => {
197
- it("should still return mr instead of consuming it as the flag value", () => {
198
- assert.equal(getPositionalCliCommand(["node", "index.js", "--use-oauth", "mr", "list"]), "mr");
199
- });
200
- });
201
- describe("with --read-only false before a curated command", () => {
202
- it("should skip the boolean literal and return mr", () => {
203
- assert.equal(getPositionalCliCommand(["node", "index.js", "--read-only", "false", "mr", "list"]), "mr");
204
- });
205
- });
206
- describe("with space-separated --api-url before auth", () => {
207
- it("should skip the URL value and return auth", () => {
208
- assert.equal(getPositionalCliCommand([
209
- "node",
210
- "index.js",
211
- "--api-url",
212
- "https://gitlab.example/api/v4",
213
- "auth",
214
- ]), "auth");
215
- });
216
- });
217
- describe("with space-separated --client-id before auth", () => {
218
- it("should skip the client id value and return auth", () => {
219
- assert.equal(getPositionalCliCommand(["node", "index.js", "--client-id", "app-id", "auth"]), "auth");
220
- });
221
- });
222
- describe("with -h before auth", () => {
223
- it("should not consume auth as a help value", () => {
224
- assert.equal(getPositionalCliCommand(["node", "index.js", "-h", "auth"]), "auth");
225
- });
226
- });
227
- });
228
- describe("When converting a GitLab API URL", () => {
229
- describe("with an /api/v4 suffix", () => {
230
- it("should strip it the same way initializeOAuthClient does", () => {
231
- assert.equal(gitlabOriginFromApiUrl("https://gitlab.com/api/v4"), "https://gitlab.com");
232
- });
233
- });
234
- describe("with a trailing slash after /api/v4", () => {
235
- it("should strip the suffix and trailing slash", () => {
236
- assert.equal(gitlabOriginFromApiUrl("https://gitlab.example/api/v4/"), "https://gitlab.example");
237
- });
238
- });
239
- });
240
- describe("When running device authorization grant", () => {
241
- describe("with authorization_pending then a token", () => {
242
- it("should return TokenData compatible with the existing OAuth store", async () => {
243
- const fetchImpl = createFetchStub({
244
- authorize: { status: 200, body: deviceAuthorizationBody() },
245
- tokens: [
246
- { status: 400, body: pendingBody() },
247
- { status: 200, body: successTokenBody() },
248
- ],
249
- });
250
- const sleep = createSleepRecorder();
251
- const token = await runDeviceAuthorizationGrantAsync({
252
- gitlabUrl: GITLAB_URL,
253
- clientId: CLIENT_ID,
254
- scopes: ["api"],
255
- fetchImpl,
256
- sleepAsync: sleep.sleepAsync,
257
- });
258
- assert.equal(token.access_token, ACCESS_TOKEN);
259
- assert.equal(token.refresh_token, REFRESH_TOKEN);
260
- assert.equal(token.expires_in, 7200);
261
- assert.equal(token.token_type, "Bearer");
262
- assert.equal(typeof token.created_at, "number");
263
- assert.deepEqual(sleep.delays, [5000]);
264
- });
265
- });
266
- describe("with slow_down then a token", () => {
267
- it("should wait the increased interval before the next poll", async () => {
268
- const fetchImpl = createFetchStub({
269
- authorize: { status: 200, body: deviceAuthorizationBody(5) },
270
- tokens: [
271
- { status: 400, body: { error: "slow_down" } },
272
- { status: 200, body: successTokenBody() },
273
- ],
274
- });
275
- const sleep = createSleepRecorder();
276
- await runDeviceAuthorizationGrantAsync({
277
- gitlabUrl: GITLAB_URL,
278
- clientId: CLIENT_ID,
279
- scopes: ["api"],
280
- fetchImpl,
281
- sleepAsync: sleep.sleepAsync,
282
- });
283
- assert.deepEqual(sleep.delays, [10000]);
284
- });
285
- });
286
- });
287
- describe("When the token endpoint returns expired_token", () => {
288
- describe("with no token file yet", () => {
289
- it("should throw and not write a token file", async () => {
290
- const tokenPath = createTempTokenPath();
291
- const fetchImpl = createFetchStub({
292
- authorize: { status: 200, body: deviceAuthorizationBody() },
293
- tokens: [{ status: 400, body: { error: "expired_token" } }],
294
- });
295
- const oauth = createOauthClient(tokenPath);
296
- await assert.rejects(() => oauth.runDeviceFlowAsync({ fetchImpl, sleepAsync: async () => { } }), /Device code expired/);
297
- assert.equal(fs.existsSync(tokenPath), false);
298
- });
299
- });
300
- });
301
- describe("When the token endpoint returns access_denied", () => {
302
- describe("with no token file yet", () => {
303
- it("should throw and not write a token file", async () => {
304
- const tokenPath = createTempTokenPath();
305
- const fetchImpl = createFetchStub({
306
- authorize: { status: 200, body: deviceAuthorizationBody() },
307
- tokens: [{ status: 400, body: { error: "access_denied" } }],
308
- });
309
- const oauth = createOauthClient(tokenPath);
310
- await assert.rejects(() => oauth.runDeviceFlowAsync({ fetchImpl, sleepAsync: async () => { } }), /Authorization was denied/);
311
- assert.equal(fs.existsSync(tokenPath), false);
312
- });
313
- });
314
- });
315
- describe("When GitLab returns 404 for authorize_device", () => {
316
- describe("with a self-managed instance", () => {
317
- it("should tell the user to use a PAT", async () => {
318
- const fetchImpl = createFetchStub({
319
- authorize: { status: 404, body: { error: "not_found" } },
320
- tokens: [],
321
- });
322
- await assert.rejects(() => runDeviceAuthorizationGrantAsync({
323
- gitlabUrl: GITLAB_URL,
324
- clientId: CLIENT_ID,
325
- scopes: ["api"],
326
- fetchImpl,
327
- sleepAsync: async () => { },
328
- }), /GITLAB_PERSONAL_ACCESS_TOKEN/);
329
- });
330
- });
331
- });
332
- describe("When device flow succeeds", () => {
333
- describe("with GitLabOAuth.saveToken", () => {
334
- it("should persist the existing token file shape", async () => {
335
- const tokenPath = createTempTokenPath();
336
- const fetchImpl = createFetchStub({
337
- authorize: { status: 200, body: deviceAuthorizationBody() },
338
- tokens: [{ status: 200, body: successTokenBody() }],
339
- });
340
- try {
341
- await createOauthClient(tokenPath).runDeviceFlowAsync({
342
- fetchImpl,
343
- sleepAsync: async () => { },
344
- });
345
- const raw = JSON.parse(fs.readFileSync(tokenPath, "utf8"));
346
- const stored = readStoredToken(raw);
347
- assert.equal(stored.access_token, ACCESS_TOKEN);
348
- assert.equal(stored.refresh_token, REFRESH_TOKEN);
349
- assert.equal(stored.expires_in, 7200);
350
- assert.equal(stored.token_type, "Bearer");
351
- assert.equal(typeof stored.created_at, "number");
352
- assert.equal(fs.statSync(tokenPath).mode & 0o777, 0o600);
353
- }
354
- finally {
355
- if (fs.existsSync(tokenPath)) {
356
- fs.unlinkSync(tokenPath);
357
- }
358
- }
359
- });
360
- });
361
- });
362
- describe("When running the auth command", () => {
363
- describe("with --help", () => {
364
- it("should print usage and not fetch", async () => {
365
- const out = createStdoutBuffer();
366
- const fetchImpl = async () => {
367
- throw new Error("fetch should not run for --help");
368
- };
369
- await runAuthCommandAsync({
370
- argv: ["node", "index.js", "auth", "--help"],
371
- env: {},
372
- stdout: out.stdout,
373
- fetchImpl,
374
- });
375
- assert.match(out.text(), /zereight-mcp-gitlab auth/);
376
- assert.equal(out.text().includes(DEVICE_CODE), false);
377
- });
378
- });
379
- describe("without a client id", () => {
380
- it("should fail before calling GitLab", async () => {
381
- await assert.rejects(() => runAuthCommandAsync({
382
- argv: ["node", "index.js", "auth"],
383
- env: {},
384
- stdout: createStdoutBuffer().stdout,
385
- fetchImpl: async () => {
386
- throw new Error("fetch should not run without client id");
387
- },
388
- }), /GITLAB_OAUTH_CLIENT_ID/);
389
- });
390
- });
391
- describe("with a successful device flow", () => {
392
- it("should print the user code and not the device code or access token", async () => {
393
- const tokenPath = createTempTokenPath();
394
- const out = createStdoutBuffer();
395
- const fetchImpl = createFetchStub({
396
- authorize: { status: 200, body: deviceAuthorizationBody() },
397
- tokens: [{ status: 200, body: successTokenBody() }],
398
- });
399
- try {
400
- await runAuthCommandAsync({
401
- argv: [
402
- "node",
403
- "index.js",
404
- "auth",
405
- `--client-id=${CLIENT_ID}`,
406
- `--api-url=${GITLAB_URL}`,
407
- `--token-path=${tokenPath}`,
408
- ],
409
- env: {},
410
- stdout: out.stdout,
411
- fetchImpl,
412
- sleepAsync: async () => { },
413
- });
414
- const printed = out.text();
415
- assert.equal(printed.includes(USER_CODE), true);
416
- assert.match(printed, /Token saved to/);
417
- assert.match(printed, /GITLAB_USE_OAUTH=true/);
418
- assert.match(printed, /GITLAB_OAUTH_TOKEN_PATH/);
419
- assert.equal(printed.includes(DEVICE_CODE), false);
420
- assert.equal(printed.includes(ACCESS_TOKEN), false);
421
- assert.equal(fs.existsSync(tokenPath), true);
422
- }
423
- finally {
424
- if (fs.existsSync(tokenPath)) {
425
- fs.unlinkSync(tokenPath);
426
- }
427
- }
428
- });
429
- });
430
- describe("without a read-only flag", () => {
431
- it("should request the api scope", async () => {
432
- assert.equal(await runAuthCapturingScopeAsync({}), "api");
433
- });
434
- });
435
- describe("with --permission-mode=readonly", () => {
436
- it("should request the read_api scope", async () => {
437
- assert.equal(await runAuthCapturingScopeAsync({ extraArgv: ["--permission-mode=readonly"] }), "read_api");
438
- });
439
- });
440
- describe("with GITLAB_PERMISSION_MODE=readonly", () => {
441
- it("should request the read_api scope", async () => {
442
- assert.equal(await runAuthCapturingScopeAsync({ env: { GITLAB_PERMISSION_MODE: "readonly" } }), "read_api");
443
- });
444
- });
445
- describe("with --read-only=true", () => {
446
- it("should request the read_api scope", async () => {
447
- assert.equal(await runAuthCapturingScopeAsync({ extraArgv: ["--read-only=true"] }), "read_api");
448
- });
449
- });
450
- });
@@ -1,157 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { spawn } from "node:child_process";
3
- import * as fs from "node:fs";
4
- import * as net from "node:net";
5
- import * as os from "node:os";
6
- import * as path from "node:path";
7
- import * as url from "node:url";
8
- import { describe, it } from "node:test";
9
- // The token key is derived at module load, so the secret must be set before importing it.
10
- const DOWNLOAD_TOKEN_SECRET = "oauth-download-test-secret";
11
- process.env.DOWNLOAD_TOKEN_SECRET = DOWNLOAD_TOKEN_SECRET;
12
- const { decryptDownloadToken } = await import("../utils/download-token.js");
13
- const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
14
- const SERVER_ENTRY = path.resolve(__dirname, "../build/index.js");
15
- const HOST = "127.0.0.1";
16
- const OAUTH_TOKEN = "oauth-download-test";
17
- const MCP_ACCESS_TOKEN = "mcp-access-test";
18
- const STARTUP_TIMEOUT_MS = 15_000;
19
- const MCP_HEADERS = {
20
- "Content-Type": "application/json",
21
- Accept: "application/json, text/event-stream",
22
- Authorization: `Bearer ${MCP_ACCESS_TOKEN}`,
23
- };
24
- function getFreePortAsync() {
25
- return new Promise((resolve, reject) => {
26
- const probe = net.createServer();
27
- probe.on("error", reject);
28
- probe.listen(0, HOST, () => {
29
- const address = probe.address();
30
- if (address === null || typeof address === "string") {
31
- probe.close();
32
- reject(new Error("probe server is not listening on a TCP port"));
33
- return;
34
- }
35
- probe.close(() => resolve(address.port));
36
- });
37
- });
38
- }
39
- function buildServerEnv(port, tmpDir) {
40
- const tokenPath = path.join(tmpDir, "token.json");
41
- fs.writeFileSync(tokenPath, JSON.stringify({
42
- access_token: OAUTH_TOKEN,
43
- refresh_token: "refresh",
44
- expires_in: 7200,
45
- created_at: Date.now(),
46
- token_type: "Bearer",
47
- }));
48
- const env = {};
49
- for (const [key, value] of Object.entries(process.env)) {
50
- if (value !== undefined && !key.startsWith("GITLAB_")) {
51
- env[key] = value;
52
- }
53
- }
54
- return {
55
- ...env,
56
- STREAMABLE_HTTP: "true",
57
- HOST,
58
- PORT: String(port),
59
- MCP_SERVER_URL: "",
60
- GITLAB_API_URL: `http://${HOST}:1/api/v4`,
61
- GITLAB_USE_OAUTH: "true",
62
- GITLAB_OAUTH_CLIENT_ID: "client-id",
63
- GITLAB_OAUTH_TOKEN_PATH: tokenPath,
64
- GITLAB_IS_OLD: "true",
65
- USE_PIPELINE: "true",
66
- DOWNLOAD_TOKEN_SECRET,
67
- STREAMABLE_HTTP_AUTH_TOKEN: MCP_ACCESS_TOKEN,
68
- };
69
- }
70
- async function waitForServerAsync(port, child) {
71
- const deadline = Date.now() + STARTUP_TIMEOUT_MS;
72
- while (Date.now() < deadline) {
73
- if (child.exitCode !== null) {
74
- throw new Error(`server exited early with code ${child.exitCode}`);
75
- }
76
- const reachable = await new Promise(resolve => {
77
- const socket = net.connect(port, HOST);
78
- socket.once("connect", () => {
79
- socket.destroy();
80
- resolve(true);
81
- });
82
- socket.once("error", () => resolve(false));
83
- });
84
- if (reachable) {
85
- return;
86
- }
87
- await new Promise(resolve => setTimeout(resolve, 100));
88
- }
89
- throw new Error("server did not start listening in time");
90
- }
91
- async function postMcpAsync(port, body, sessionId) {
92
- return fetch(`http://${HOST}:${port}/mcp`, {
93
- method: "POST",
94
- headers: sessionId ? { ...MCP_HEADERS, "mcp-session-id": sessionId } : MCP_HEADERS,
95
- body: JSON.stringify(body),
96
- });
97
- }
98
- function readJsonRpcResult(sseBody) {
99
- const dataLine = sseBody.split("\n").find(line => line.startsWith("data: "));
100
- if (dataLine === undefined) {
101
- throw new Error(`no SSE data line in response: ${sseBody}`);
102
- }
103
- return JSON.parse(dataLine.slice("data: ".length));
104
- }
105
- function readDownloadUrl(rpc) {
106
- const text = JSON.parse(JSON.stringify(rpc)).result?.content?.[0]?.text;
107
- if (typeof text !== "string") {
108
- throw new Error(`unexpected tool response: ${JSON.stringify(rpc)}`);
109
- }
110
- const downloadUrl = JSON.parse(text).download_url;
111
- if (typeof downloadUrl !== "string") {
112
- throw new Error(`no download_url in tool response: ${text}`);
113
- }
114
- return downloadUrl;
115
- }
116
- async function requestDownloadUrlAsync() {
117
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gitlab-oauth-download-"));
118
- const port = await getFreePortAsync();
119
- const env = buildServerEnv(port, tmpDir);
120
- const child = spawn(process.execPath, [SERVER_ENTRY], { env, stdio: "ignore" });
121
- try {
122
- await waitForServerAsync(port, child);
123
- const init = await postMcpAsync(port, {
124
- jsonrpc: "2.0",
125
- id: 1,
126
- method: "initialize",
127
- params: {
128
- protocolVersion: "2025-03-26",
129
- capabilities: {},
130
- clientInfo: { name: "oauth-download-url-test", version: "1.0" },
131
- },
132
- });
133
- const sessionId = init.headers.get("mcp-session-id") ?? undefined;
134
- const call = await postMcpAsync(port, {
135
- jsonrpc: "2.0",
136
- id: 2,
137
- method: "tools/call",
138
- params: { name: "download_job_artifacts", arguments: { project_id: "g/p", job_id: "1" } },
139
- }, sessionId);
140
- return readDownloadUrl(readJsonRpcResult(await call.text()));
141
- }
142
- finally {
143
- child.kill("SIGKILL");
144
- fs.rmSync(tmpDir, { recursive: true, force: true });
145
- }
146
- }
147
- describe("When a remote server builds a download URL with a stored OAuth token", () => {
148
- describe("with GITLAB_IS_OLD enabled", () => {
149
- it("should embed the token as a bearer credential", async () => {
150
- const downloadUrl = await requestDownloadUrlAsync();
151
- const embedded = new URL(downloadUrl).searchParams.get("_token");
152
- const decrypted = embedded === null ? null : decryptDownloadToken(embedded);
153
- assert.equal(decrypted?.header, "Authorization");
154
- assert.equal(decrypted?.token, `Bearer ${OAUTH_TOKEN}`);
155
- });
156
- });
157
- });