@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,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
- });