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