@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,321 +0,0 @@
1
- /**
2
- * Callback Proxy Mode Tests
3
- *
4
- * Tests for GITLAB_OAUTH_CALLBACK_PROXY=true — the mode where the MCP server
5
- * intercepts the OAuth callback from GitLab, exchanges the code for tokens
6
- * server-side, and redirects to the MCP client with a proxy code.
7
- */
8
- import { describe, test, after, before } from "node:test";
9
- import assert from "node:assert";
10
- import { createHash, randomBytes } from "node:crypto";
11
- import { launchServer, findAvailablePort, cleanupServers, TransportMode, HOST, } from "./utils/server-launcher.js";
12
- import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js";
13
- const MOCK_GITLAB_PORT_BASE = 9400;
14
- const MCP_SERVER_PORT_BASE = 3400;
15
- const MOCK_ACCESS_TOKEN = "ya29.mock-callback-proxy-token-123456";
16
- const MOCK_REFRESH_TOKEN = "mock-refresh-token-abcdef";
17
- const MOCK_APP_ID = "test-callback-proxy-app-id";
18
- // ---------------------------------------------------------------------------
19
- // Helpers
20
- // ---------------------------------------------------------------------------
21
- function generatePKCE() {
22
- const verifier = randomBytes(32).toString("base64url");
23
- const challenge = createHash("sha256").update(verifier).digest("base64url");
24
- return { verifier, challenge };
25
- }
26
- /** Register a client via DCR and return the virtual client_id. */
27
- async function registerClient(mcpBaseUrl, redirectUri) {
28
- const res = await fetch(`${mcpBaseUrl}/register`, {
29
- method: "POST",
30
- headers: { "Content-Type": "application/json" },
31
- body: JSON.stringify({
32
- redirect_uris: [redirectUri],
33
- client_name: "callback-proxy-test",
34
- }),
35
- });
36
- assert.strictEqual(res.status, 201, `DCR failed: ${res.status}`);
37
- const data = (await res.json());
38
- return data.client_id;
39
- }
40
- /** Hit /authorize and return the redirect Location without following it. */
41
- async function authorize(mcpBaseUrl, clientId, redirectUri, codeChallenge, state) {
42
- const params = new URLSearchParams({
43
- response_type: "code",
44
- client_id: clientId,
45
- redirect_uri: redirectUri,
46
- code_challenge: codeChallenge,
47
- code_challenge_method: "S256",
48
- state,
49
- scope: "api",
50
- });
51
- const res = await fetch(`${mcpBaseUrl}/authorize?${params}`, {
52
- redirect: "manual",
53
- });
54
- assert.strictEqual(res.status, 302, `Expected 302, got ${res.status}`);
55
- const location = res.headers.get("location");
56
- assert.ok(location, "Missing Location header");
57
- return new URL(location);
58
- }
59
- /** Simulate GitLab redirecting to the MCP server's /callback. */
60
- async function simulateGitLabCallback(mcpBaseUrl, code, state) {
61
- const res = await fetch(`${mcpBaseUrl}/callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`, { redirect: "manual" });
62
- return {
63
- status: res.status,
64
- location: res.headers.get("location"),
65
- body: await res.text(),
66
- };
67
- }
68
- /** Exchange a proxy code for tokens via /token. */
69
- async function exchangeToken(mcpBaseUrl, clientId, code, codeVerifier, redirectUri) {
70
- const res = await fetch(`${mcpBaseUrl}/token`, {
71
- method: "POST",
72
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
73
- body: new URLSearchParams({
74
- grant_type: "authorization_code",
75
- client_id: clientId,
76
- code,
77
- code_verifier: codeVerifier,
78
- redirect_uri: redirectUri,
79
- }).toString(),
80
- });
81
- return {
82
- status: res.status,
83
- body: (await res.json()),
84
- };
85
- }
86
- // ---------------------------------------------------------------------------
87
- // Test suite
88
- // ---------------------------------------------------------------------------
89
- describe("Callback Proxy Mode", () => {
90
- let mcpBaseUrl;
91
- let mockGitLab;
92
- let mockGitLabUrl;
93
- let servers = [];
94
- const clientRedirectUri = "http://localhost:19999/oauth/callback";
95
- // Track the last code+verifier GitLab received so we can verify the proxy PKCE
96
- let lastTokenRequest = {};
97
- before(async () => {
98
- const mockPort = await findMockServerPort();
99
- mockGitLab = new MockGitLabServer({
100
- port: mockPort,
101
- validTokens: [MOCK_ACCESS_TOKEN],
102
- });
103
- await mockGitLab.start();
104
- mockGitLabUrl = mockGitLab.getUrl();
105
- // Mock GitLab token exchange — returns tokens and records the request
106
- mockGitLab.addRootHandler("post", "/oauth/token", (req, res) => {
107
- // Body may be URL-encoded (from handleCallback) — parse manually if needed
108
- let body = req.body ?? {};
109
- if (typeof body === "string") {
110
- body = Object.fromEntries(new URLSearchParams(body));
111
- }
112
- else if (!body.grant_type && req.headers["content-type"]?.includes("urlencoded")) {
113
- // express.json() didn't parse it — collect raw body
114
- let raw = "";
115
- req.on("data", (chunk) => { raw += chunk.toString(); });
116
- req.on("end", () => {
117
- lastTokenRequest = Object.fromEntries(new URLSearchParams(raw));
118
- res.json({
119
- access_token: MOCK_ACCESS_TOKEN,
120
- token_type: "Bearer",
121
- expires_in: 7200,
122
- refresh_token: MOCK_REFRESH_TOKEN,
123
- scope: "api",
124
- });
125
- });
126
- return;
127
- }
128
- lastTokenRequest = body;
129
- res.json({
130
- access_token: MOCK_ACCESS_TOKEN,
131
- token_type: "Bearer",
132
- expires_in: 7200,
133
- refresh_token: MOCK_REFRESH_TOKEN,
134
- scope: "api",
135
- });
136
- });
137
- // Mock token introspection
138
- mockGitLab.addRootHandler("get", "/oauth/token/info", (req, res) => {
139
- const auth = req.headers["authorization"];
140
- const token = auth?.replace(/^Bearer\s+/i, "");
141
- if (token !== MOCK_ACCESS_TOKEN) {
142
- res.status(401).json({ error: "invalid_token" });
143
- return;
144
- }
145
- res.json({
146
- resource_owner_id: 42,
147
- scopes: ["api"],
148
- expires_in_seconds: 7200,
149
- application: { uid: MOCK_APP_ID },
150
- created_at: Math.floor(Date.now() / 1000),
151
- });
152
- });
153
- const mcpPort = await findAvailablePort(MCP_SERVER_PORT_BASE);
154
- mcpBaseUrl = `http://${HOST}:${mcpPort}`;
155
- const server = await launchServer({
156
- mode: TransportMode.STREAMABLE_HTTP,
157
- port: mcpPort,
158
- timeout: 5000,
159
- env: {
160
- STREAMABLE_HTTP: "true",
161
- GITLAB_MCP_OAUTH: "true",
162
- GITLAB_OAUTH_CALLBACK_PROXY: "true",
163
- GITLAB_OAUTH_APP_ID: MOCK_APP_ID,
164
- GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
165
- MCP_SERVER_URL: mcpBaseUrl,
166
- MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL: "true",
167
- },
168
- });
169
- servers.push(server);
170
- });
171
- after(async () => {
172
- cleanupServers(servers);
173
- if (mockGitLab)
174
- await mockGitLab.stop();
175
- });
176
- // ---- Happy path --------------------------------------------------------
177
- test("full flow: authorize → callback → token exchange", async () => {
178
- const clientPKCE = generatePKCE();
179
- const clientState = "test-state-123";
180
- // 1. Register client
181
- const clientId = await registerClient(mcpBaseUrl, clientRedirectUri);
182
- // 2. Authorize — should redirect to GitLab with fixed callback URL
183
- const gitlabUrl = await authorize(mcpBaseUrl, clientId, clientRedirectUri, clientPKCE.challenge, clientState);
184
- assert.strictEqual(gitlabUrl.hostname, new URL(mockGitLabUrl).hostname);
185
- assert.strictEqual(gitlabUrl.pathname, "/oauth/authorize");
186
- // redirect_uri should be the MCP server's /callback, NOT the client's
187
- assert.strictEqual(gitlabUrl.searchParams.get("redirect_uri"), `${mcpBaseUrl}/callback`);
188
- // State should be a proxy state, NOT the client's original state
189
- const proxyState = gitlabUrl.searchParams.get("state");
190
- assert.ok(proxyState);
191
- assert.notStrictEqual(proxyState, clientState);
192
- // 3. Simulate GitLab callback to MCP server
193
- const callbackResult = await simulateGitLabCallback(mcpBaseUrl, "gitlab-auth-code-xyz", proxyState);
194
- assert.strictEqual(callbackResult.status, 302);
195
- assert.ok(callbackResult.location);
196
- const clientCallbackUrl = new URL(callbackResult.location);
197
- // Should redirect to the CLIENT's callback URL
198
- assert.ok(clientCallbackUrl.href.startsWith(clientRedirectUri));
199
- // Should include a proxy code (not the GitLab code)
200
- const proxyCode = clientCallbackUrl.searchParams.get("code");
201
- assert.ok(proxyCode);
202
- assert.notStrictEqual(proxyCode, "gitlab-auth-code-xyz");
203
- // Should restore the client's original state
204
- assert.strictEqual(clientCallbackUrl.searchParams.get("state"), clientState);
205
- // 4. Exchange proxy code for tokens
206
- const tokenResult = await exchangeToken(mcpBaseUrl, clientId, proxyCode, clientPKCE.verifier, clientRedirectUri);
207
- assert.strictEqual(tokenResult.status, 200);
208
- assert.strictEqual(tokenResult.body.access_token, MOCK_ACCESS_TOKEN);
209
- assert.strictEqual(tokenResult.body.refresh_token, MOCK_REFRESH_TOKEN);
210
- });
211
- // ---- One-time use proxy code -------------------------------------------
212
- test("proxy code cannot be reused", async () => {
213
- const clientPKCE = generatePKCE();
214
- const clientId = await registerClient(mcpBaseUrl, clientRedirectUri);
215
- const gitlabUrl = await authorize(mcpBaseUrl, clientId, clientRedirectUri, clientPKCE.challenge, "state-reuse");
216
- const proxyState = gitlabUrl.searchParams.get("state");
217
- const cb = await simulateGitLabCallback(mcpBaseUrl, "code-reuse", proxyState);
218
- const proxyCode = new URL(cb.location).searchParams.get("code");
219
- // First exchange succeeds
220
- const first = await exchangeToken(mcpBaseUrl, clientId, proxyCode, clientPKCE.verifier, clientRedirectUri);
221
- assert.strictEqual(first.status, 200);
222
- // Second exchange with same code fails
223
- const second = await exchangeToken(mcpBaseUrl, clientId, proxyCode, clientPKCE.verifier, clientRedirectUri);
224
- assert.notStrictEqual(second.status, 200);
225
- });
226
- // ---- Client binding -----------------------------------------------------
227
- test("proxy code cannot be redeemed by a different client_id", async () => {
228
- const clientPKCE = generatePKCE();
229
- const clientId = await registerClient(mcpBaseUrl, clientRedirectUri);
230
- const otherClientId = await registerClient(mcpBaseUrl, "http://localhost:19998/oauth/callback");
231
- const gitlabUrl = await authorize(mcpBaseUrl, clientId, clientRedirectUri, clientPKCE.challenge, "state-client-binding");
232
- const proxyState = gitlabUrl.searchParams.get("state");
233
- const cb = await simulateGitLabCallback(mcpBaseUrl, "code-client-binding", proxyState);
234
- const proxyCode = new URL(cb.location).searchParams.get("code");
235
- const result = await exchangeToken(mcpBaseUrl, otherClientId, proxyCode, clientPKCE.verifier, clientRedirectUri);
236
- assert.notStrictEqual(result.status, 200);
237
- });
238
- test("proxy code cannot be redeemed with a different redirect_uri", async () => {
239
- const clientPKCE = generatePKCE();
240
- const clientId = await registerClient(mcpBaseUrl, clientRedirectUri);
241
- const gitlabUrl = await authorize(mcpBaseUrl, clientId, clientRedirectUri, clientPKCE.challenge, "state-redirect-binding");
242
- const proxyState = gitlabUrl.searchParams.get("state");
243
- const cb = await simulateGitLabCallback(mcpBaseUrl, "code-redirect-binding", proxyState);
244
- const proxyCode = new URL(cb.location).searchParams.get("code");
245
- const result = await exchangeToken(mcpBaseUrl, clientId, proxyCode, clientPKCE.verifier, "http://localhost:19998/oauth/callback");
246
- assert.notStrictEqual(result.status, 200);
247
- });
248
- // ---- Unknown state parameter -------------------------------------------
249
- test("callback with unknown state returns 400", async () => {
250
- const result = await simulateGitLabCallback(mcpBaseUrl, "some-code", "unknown-state-value");
251
- assert.strictEqual(result.status, 400);
252
- assert.ok(result.body.includes("Unknown or expired"));
253
- });
254
- // ---- PKCE verification failure -----------------------------------------
255
- test("token exchange with wrong code_verifier fails", async () => {
256
- const clientPKCE = generatePKCE();
257
- const clientId = await registerClient(mcpBaseUrl, clientRedirectUri);
258
- const gitlabUrl = await authorize(mcpBaseUrl, clientId, clientRedirectUri, clientPKCE.challenge, "state-pkce");
259
- const proxyState = gitlabUrl.searchParams.get("state");
260
- const cb = await simulateGitLabCallback(mcpBaseUrl, "code-pkce", proxyState);
261
- const proxyCode = new URL(cb.location).searchParams.get("code");
262
- // Exchange with WRONG verifier
263
- const result = await exchangeToken(mcpBaseUrl, clientId, proxyCode, "wrong-verifier-value", clientRedirectUri);
264
- assert.notStrictEqual(result.status, 200);
265
- });
266
- // ---- PKCE verifier omitted ---------------------------------------------
267
- test("token exchange without code_verifier fails when challenge was stored", async () => {
268
- const clientPKCE = generatePKCE();
269
- const clientId = await registerClient(mcpBaseUrl, clientRedirectUri);
270
- const gitlabUrl = await authorize(mcpBaseUrl, clientId, clientRedirectUri, clientPKCE.challenge, "state-no-verifier");
271
- const proxyState = gitlabUrl.searchParams.get("state");
272
- const cb = await simulateGitLabCallback(mcpBaseUrl, "code-no-verifier", proxyState);
273
- const proxyCode = new URL(cb.location).searchParams.get("code");
274
- // Exchange WITHOUT verifier — should fail
275
- const res = await fetch(`${mcpBaseUrl}/token`, {
276
- method: "POST",
277
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
278
- body: new URLSearchParams({
279
- grant_type: "authorization_code",
280
- client_id: clientId,
281
- code: proxyCode,
282
- redirect_uri: clientRedirectUri,
283
- }).toString(),
284
- });
285
- assert.notStrictEqual(res.status, 200);
286
- });
287
- // ---- Replayed state parameter ------------------------------------------
288
- test("state cannot be replayed after callback", async () => {
289
- const clientPKCE = generatePKCE();
290
- const clientId = await registerClient(mcpBaseUrl, clientRedirectUri);
291
- const gitlabUrl = await authorize(mcpBaseUrl, clientId, clientRedirectUri, clientPKCE.challenge, "state-replay");
292
- const proxyState = gitlabUrl.searchParams.get("state");
293
- // First callback succeeds
294
- const first = await simulateGitLabCallback(mcpBaseUrl, "code-replay-1", proxyState);
295
- assert.strictEqual(first.status, 302);
296
- // Second callback with same state fails
297
- const second = await simulateGitLabCallback(mcpBaseUrl, "code-replay-2", proxyState);
298
- assert.strictEqual(second.status, 400);
299
- });
300
- // ---- Dual PKCE verification --------------------------------------------
301
- test("proxy uses its own PKCE pair with GitLab", async () => {
302
- const clientPKCE = generatePKCE();
303
- const clientId = await registerClient(mcpBaseUrl, clientRedirectUri);
304
- const gitlabUrl = await authorize(mcpBaseUrl, clientId, clientRedirectUri, clientPKCE.challenge, "state-dual-pkce");
305
- // The code_challenge sent to GitLab should NOT be the client's challenge
306
- const gitlabChallenge = gitlabUrl.searchParams.get("code_challenge");
307
- assert.ok(gitlabChallenge);
308
- assert.notStrictEqual(gitlabChallenge, clientPKCE.challenge);
309
- // Complete the flow to verify the proxy verifier was sent to GitLab
310
- const proxyState = gitlabUrl.searchParams.get("state");
311
- await simulateGitLabCallback(mcpBaseUrl, "code-dual-pkce", proxyState);
312
- // The token request to GitLab should have a code_verifier that matches
313
- // the proxy challenge (not the client's verifier)
314
- assert.ok(lastTokenRequest.code_verifier);
315
- assert.notStrictEqual(lastTokenRequest.code_verifier, clientPKCE.verifier);
316
- const computedChallenge = createHash("sha256")
317
- .update(lastTokenRequest.code_verifier)
318
- .digest("base64url");
319
- assert.strictEqual(computedChallenge, gitlabChallenge);
320
- });
321
- });
@@ -1,175 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import { allTools } from "../../tools/registry.js";
4
- import { CreateWebhookSchema, GetPipelineJobOutputSchema, ProtectBranchSchema, } from "../../schemas.js";
5
- import { applyFlagAliases, CliUsageError, parseArgv, parseToolArgs } from "../../cli/args.js";
6
- function argv(...args) {
7
- return ["node", "index.js", ...args];
8
- }
9
- function schemaFor(toolName) {
10
- const tool = allTools.find(entry => entry.name === toolName);
11
- assert.ok(tool, `missing tool ${toolName}`);
12
- return tool.inputSchema;
13
- }
14
- describe("When parseArgv reads a command line", () => {
15
- describe("with a generic tool invocation", () => {
16
- it("should keep positionals and kebab flags as snake_case tool args", () => {
17
- const parsed = parseArgv(argv("tool", "list_issues", "--project-id", "123", "--state", "opened"));
18
- assert.deepEqual(parsed.positionals, ["tool", "list_issues"]);
19
- assert.equal(parsed.toolFlags.project_id, "123");
20
- assert.equal(parsed.toolFlags.state, "opened");
21
- assert.equal(parsed.output, undefined);
22
- });
23
- });
24
- describe("with global flags mixed in", () => {
25
- it("should not treat --token --output --yes as tool flags", () => {
26
- const parsed = parseArgv(argv("tool", "whoami", "--token", "glpat-xxxxxxxxxxxxxxxxxxxx", "--output=json", "--yes"));
27
- assert.equal(parsed.output, "json");
28
- assert.equal(parsed.yes, true);
29
- assert.deepEqual(parsed.toolFlags, {});
30
- });
31
- });
32
- describe("with --args-json", () => {
33
- it("should parse the object and ignore prototype keys", () => {
34
- const parsed = parseArgv(argv("tool", "create_issue", "--args-json", '{"title":"bug","__proto__":{"x":1}}'));
35
- assert.equal(parsed.argsJson?.title, "bug");
36
- assert.equal(Object.prototype.hasOwnProperty.call(parsed.argsJson, "__proto__"), false);
37
- });
38
- });
39
- describe("with --use-oauth before a curated command", () => {
40
- it("should keep mr list as positionals instead of consuming mr as the flag value", () => {
41
- const parsed = parseArgv(argv("--use-oauth", "mr", "list"));
42
- assert.deepEqual(parsed.positionals, ["mr", "list"]);
43
- });
44
- });
45
- describe("with --read-only false before a curated command", () => {
46
- it("should keep false as the flag value and mr list as positionals", () => {
47
- const parsed = parseArgv(argv("--read-only", "false", "mr", "list"));
48
- assert.deepEqual(parsed.positionals, ["mr", "list"]);
49
- });
50
- });
51
- });
52
- describe("When parseToolArgs validates a real schema", () => {
53
- describe("with whoami", () => {
54
- it("should accept an empty flag set", () => {
55
- const args = parseToolArgs({
56
- schema: schemaFor("whoami"),
57
- flags: {},
58
- argsJson: undefined,
59
- extraArgs: undefined,
60
- });
61
- assert.deepEqual(args, {});
62
- });
63
- });
64
- describe("with create_issue missing title", () => {
65
- it("should throw a usage error for the required flag", () => {
66
- assert.throws(() => parseToolArgs({
67
- schema: schemaFor("create_issue"),
68
- flags: { project_id: "123" },
69
- argsJson: undefined,
70
- extraArgs: undefined,
71
- }), (error) => error instanceof CliUsageError && error.message.includes("--title"));
72
- });
73
- });
74
- describe("with flags overlaying --args-json", () => {
75
- it("should let flags win", () => {
76
- const args = parseToolArgs({
77
- schema: schemaFor("create_issue"),
78
- flags: { title: "from-flag" },
79
- argsJson: { project_id: "123", title: "from-json" },
80
- extraArgs: undefined,
81
- });
82
- assert.equal(args.project_id, "123");
83
- assert.equal(args.title, "from-flag");
84
- });
85
- });
86
- describe("with an unknown flag", () => {
87
- it("should throw a usage error", () => {
88
- assert.throws(() => parseToolArgs({
89
- schema: schemaFor("whoami"),
90
- flags: { nope: "1" },
91
- argsJson: undefined,
92
- extraArgs: undefined,
93
- }), (error) => error instanceof CliUsageError && error.message === "Unknown flag: --nope");
94
- });
95
- });
96
- });
97
- describe("When parseToolArgs coerces scalar flags from the schema", () => {
98
- describe("with a number flag", () => {
99
- it("should pass a number that the tool schema accepts", () => {
100
- const args = parseToolArgs({
101
- schema: schemaFor("get_pipeline_job_output"),
102
- flags: { project_id: "1", job_id: "9", limit: "10" },
103
- argsJson: undefined,
104
- extraArgs: undefined,
105
- });
106
- assert.equal(GetPipelineJobOutputSchema.safeParse(args).success, true);
107
- });
108
- });
109
- describe("with a boolean flag", () => {
110
- it("should pass a boolean that the tool schema accepts", () => {
111
- const args = parseToolArgs({
112
- schema: schemaFor("create_webhook"),
113
- flags: { project_id: "1", url: "https://example.com/hook", push_events: "false" },
114
- argsJson: undefined,
115
- extraArgs: undefined,
116
- });
117
- assert.equal(CreateWebhookSchema.safeParse(args).success, true);
118
- });
119
- });
120
- describe("with an integer flag", () => {
121
- it("should pass an integer that the tool schema accepts", () => {
122
- const args = parseToolArgs({
123
- schema: schemaFor("protect_branch"),
124
- flags: { project_id: "1", branch_name: "main", push_access_level: "40" },
125
- argsJson: undefined,
126
- extraArgs: undefined,
127
- });
128
- assert.strictEqual(args.push_access_level, 40);
129
- assert.equal(ProtectBranchSchema.safeParse(args).success, true);
130
- });
131
- });
132
- describe("with a field that also accepts strings", () => {
133
- it("should keep the raw string", () => {
134
- const args = parseToolArgs({
135
- schema: schemaFor("list_webhook_events"),
136
- flags: { project_id: "1", hook_id: "2", status: "500" },
137
- argsJson: undefined,
138
- extraArgs: undefined,
139
- });
140
- assert.equal(args.status, "500");
141
- });
142
- });
143
- describe("with a non-boolean value for a boolean flag", () => {
144
- it("should throw a usage error", () => {
145
- assert.throws(() => parseToolArgs({
146
- schema: schemaFor("create_webhook"),
147
- flags: { project_id: "1", url: "https://example.com/hook", push_events: "yes" },
148
- argsJson: undefined,
149
- extraArgs: undefined,
150
- }), (error) => error instanceof CliUsageError && error.message === "--push-events must be true or false");
151
- });
152
- });
153
- describe("with a fraction for an integer flag", () => {
154
- it("should throw a usage error", () => {
155
- assert.throws(() => parseToolArgs({
156
- schema: schemaFor("protect_branch"),
157
- flags: { project_id: "1", branch_name: "main", push_access_level: "40.5" },
158
- argsJson: undefined,
159
- extraArgs: undefined,
160
- }), (error) => error instanceof CliUsageError &&
161
- error.message === "--push-access-level must be an integer");
162
- });
163
- });
164
- });
165
- describe("When applyFlagAliases rewrites curated shorts", () => {
166
- describe("with --mr-iid", () => {
167
- it("should map onto merge_request_iid", () => {
168
- const mapped = applyFlagAliases({ mr_iid: "45", project_id: "123" }, {
169
- mr_iid: "merge_request_iid",
170
- });
171
- assert.equal(mapped.merge_request_iid, "45");
172
- assert.equal(mapped.project_id, "123");
173
- });
174
- });
175
- });
@@ -1,106 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { execFileSync } from "node:child_process";
3
- import * as path from "node:path";
4
- import * as url from "node:url";
5
- import { describe, it } from "node:test";
6
- import { isBooleanFlagLiteral, nextBooleanFlagValue } from "../../cli-boolean-flags.js";
7
- const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
8
- const SNAPSHOT_PATH = path.resolve(__dirname, "load-config-snapshot.ts");
9
- function loadConfig(argv) {
10
- const childEnv = {};
11
- for (const [key, value] of Object.entries(process.env)) {
12
- if (value !== undefined) {
13
- childEnv[key] = value;
14
- }
15
- }
16
- delete childEnv.GITLAB_READ_ONLY_MODE;
17
- delete childEnv.GITLAB_USE_OAUTH;
18
- const stdout = execFileSync(process.execPath, ["--import", "tsx/esm", SNAPSHOT_PATH, ...argv], {
19
- env: childEnv,
20
- encoding: "utf8",
21
- stdio: ["ignore", "pipe", "pipe"],
22
- });
23
- return readBooleanConfigSnapshot(JSON.parse(stdout));
24
- }
25
- function readBooleanConfigSnapshot(value) {
26
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
27
- throw new Error("config snapshot was not an object");
28
- }
29
- if (!("GITLAB_READ_ONLY_MODE" in value) || typeof value.GITLAB_READ_ONLY_MODE !== "boolean") {
30
- throw new Error("GITLAB_READ_ONLY_MODE missing");
31
- }
32
- if (!("USE_OAUTH" in value) || typeof value.USE_OAUTH !== "boolean") {
33
- throw new Error("USE_OAUTH missing");
34
- }
35
- return {
36
- GITLAB_READ_ONLY_MODE: value.GITLAB_READ_ONLY_MODE,
37
- USE_OAUTH: value.USE_OAUTH,
38
- };
39
- }
40
- describe("When nextBooleanFlagValue reads the following token", () => {
41
- describe("with a positional command", () => {
42
- it("should treat the flag as true and leave the command", () => {
43
- assert.deepEqual(nextBooleanFlagValue("mr"), { value: "true", consumeNext: false });
44
- });
45
- });
46
- describe("with an explicit false", () => {
47
- it("should consume the literal", () => {
48
- assert.deepEqual(nextBooleanFlagValue("false"), { value: "false", consumeNext: true });
49
- });
50
- });
51
- describe("with a missing token", () => {
52
- it("should default to true", () => {
53
- assert.deepEqual(nextBooleanFlagValue(undefined), { value: "true", consumeNext: false });
54
- });
55
- });
56
- });
57
- describe("When isBooleanFlagLiteral inspects a token", () => {
58
- describe("with true", () => {
59
- it("should accept it", () => {
60
- assert.equal(isBooleanFlagLiteral("true"), true);
61
- });
62
- });
63
- describe("with a command name", () => {
64
- it("should reject it", () => {
65
- assert.equal(isBooleanFlagLiteral("mr"), false);
66
- });
67
- });
68
- });
69
- describe("When config.ts parses space-separated boolean flags", () => {
70
- describe("with --read-only as the last argument", () => {
71
- it("should turn read-only mode on", () => {
72
- const cfg = loadConfig(["--read-only"]);
73
- assert.equal(cfg.GITLAB_READ_ONLY_MODE, true);
74
- });
75
- });
76
- describe("with --read-only followed by another flag", () => {
77
- it("should turn read-only mode on", () => {
78
- const cfg = loadConfig(["--read-only", "--use-pipeline"]);
79
- assert.equal(cfg.GITLAB_READ_ONLY_MODE, true);
80
- });
81
- });
82
- describe("with an empty --read-only= assignment", () => {
83
- it("should leave read-only mode off", () => {
84
- const cfg = loadConfig(["--read-only="]);
85
- assert.equal(cfg.GITLAB_READ_ONLY_MODE, false);
86
- });
87
- });
88
- describe("with --read-only false", () => {
89
- it("should keep read-only off", () => {
90
- const cfg = loadConfig(["--read-only", "false"]);
91
- assert.equal(cfg.GITLAB_READ_ONLY_MODE, false);
92
- });
93
- });
94
- describe("with --read-only before a command", () => {
95
- it("should turn read-only on without consuming mr", () => {
96
- const cfg = loadConfig(["--read-only", "mr", "list"]);
97
- assert.equal(cfg.GITLAB_READ_ONLY_MODE, true);
98
- });
99
- });
100
- describe("with --use-oauth false", () => {
101
- it("should keep oauth off", () => {
102
- const cfg = loadConfig(["--use-oauth", "false"]);
103
- assert.equal(cfg.USE_OAUTH, false);
104
- });
105
- });
106
- });