@zereight/mcp-gitlab 2.1.69 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.ko.md +10 -4
  2. package/README.md +238 -233
  3. package/README.pt-BR.md +4 -4
  4. package/README.zh-CN.md +6 -4
  5. package/build/config.js +12 -4
  6. package/build/deprecated-env.js +47 -0
  7. package/build/index.js +456 -237
  8. package/build/oauth-device-flow.js +80 -0
  9. package/build/oauth.js +135 -18
  10. package/build/schemas.js +1 -1
  11. package/build/server/remote-http-stack.js +39 -0
  12. package/build/tools/discover-activation.js +38 -0
  13. package/build/tools/exposed-tools.js +35 -0
  14. package/build/tools/listed-tools.js +76 -0
  15. package/build/tools/registry.js +226 -57
  16. package/build/tools/tool-profile.js +66 -0
  17. package/build/utils/compact-tool-allowlist.js +14 -0
  18. package/build/utils/logger.js +34 -12
  19. package/build/utils/url.js +15 -0
  20. package/package.json +3 -3
  21. package/build/scripts/check-skill-sync.js +0 -143
  22. package/build/scripts/generate-server-card.js +0 -39
  23. package/build/scripts/generate-tool-coverage.js +0 -58
  24. package/build/scripts/generate-tool-docs.js +0 -430
  25. package/build/scripts/tool-coverage/coverage.js +0 -341
  26. package/build/scripts/tool-coverage/coverage.test.js +0 -142
  27. package/build/test/callback-proxy-tests.js +0 -321
  28. package/build/test/cli/args.test.js +0 -175
  29. package/build/test/cli/boolean-flags.test.js +0 -106
  30. package/build/test/cli/compact-mcp-result.test.js +0 -93
  31. package/build/test/cli/exposure.test.js +0 -104
  32. package/build/test/cli/load-config-snapshot.js +0 -5
  33. package/build/test/cli/output.test.js +0 -74
  34. package/build/test/cli/replay-command.test.js +0 -37
  35. package/build/test/cli/router.test.js +0 -183
  36. package/build/test/client-pool-test.js +0 -109
  37. package/build/test/clients/client.js +0 -34
  38. package/build/test/clients/custom-header-client.js +0 -122
  39. package/build/test/clients/sse-client.js +0 -95
  40. package/build/test/clients/stdio-client.js +0 -113
  41. package/build/test/clients/streamable-http-client.js +0 -95
  42. package/build/test/config-allowed-groups.test.js +0 -97
  43. package/build/test/create-commit-actions.test.js +0 -102
  44. package/build/test/draft-note-schema.test.js +0 -44
  45. package/build/test/dynamic-api-url-allowlist.test.js +0 -121
  46. package/build/test/dynamic-api-url-test.js +0 -304
  47. package/build/test/dynamic-routing-tests.js +0 -495
  48. package/build/test/get-merge-request-discussion-schema.test.js +0 -30
  49. package/build/test/gitlab-artifact-entry-schema.test.js +0 -44
  50. package/build/test/gitlab-diff-schema.test.js +0 -36
  51. package/build/test/gitlab-pipeline-schedule-schema.test.js +0 -32
  52. package/build/test/gitlab-tag-schema.test.js +0 -51
  53. package/build/test/group-milestone-schema.test.js +0 -45
  54. package/build/test/mcp-oauth-tests.js +0 -905
  55. package/build/test/mcp-server-name.test.js +0 -87
  56. package/build/test/merge-merge-request-schema.test.js +0 -75
  57. package/build/test/multi-server-test.js +0 -190
  58. package/build/test/no-proxy-integration-test.js +0 -183
  59. package/build/test/no-proxy-test.js +0 -149
  60. package/build/test/nullable-gitlab-response-fields.test.js +0 -37
  61. package/build/test/nullish-tool-arguments-schema.test.js +0 -118
  62. package/build/test/oauth-device-flow-tests.js +0 -450
  63. package/build/test/oauth-startup.test.js +0 -204
  64. package/build/test/oauth-tests.js +0 -428
  65. package/build/test/path-segment-encoding.test.js +0 -98
  66. package/build/test/remote-auth-simple-test.js +0 -277
  67. package/build/test/remote-auth-tests.js +0 -314
  68. package/build/test/response-masking.test.js +0 -445
  69. package/build/test/schema-tests.js +0 -1679
  70. package/build/test/server/metrics.test.js +0 -47
  71. package/build/test/server/request-helpers.test.js +0 -48
  72. package/build/test/server/version.test.js +0 -13
  73. package/build/test/sse-auth-guard.test.js +0 -96
  74. package/build/test/sse-session-limits.test.js +0 -273
  75. package/build/test/stateless/callback-proxy.test.js +0 -510
  76. package/build/test/stateless/client-id.test.js +0 -177
  77. package/build/test/stateless/codec.test.js +0 -328
  78. package/build/test/stateless/config-ttl.test.js +0 -149
  79. package/build/test/stateless/consumed-proxy-code-cache.test.js +0 -150
  80. package/build/test/stateless/session-id-integration.test.js +0 -675
  81. package/build/test/stateless/session-id.test.js +0 -157
  82. package/build/test/streamable-http-concurrent-session.test.js +0 -171
  83. package/build/test/streamable-http-dns-rebinding.test.js +0 -185
  84. package/build/test/streamable-http-sse-stream.test.js +0 -350
  85. package/build/test/streamable-http-static-token-auth.test.js +0 -157
  86. package/build/test/streamable-http-unauthenticated-discovery.test.js +0 -207
  87. package/build/test/test-all-transport-server.js +0 -255
  88. package/build/test/test-auth-retry.js +0 -195
  89. package/build/test/test-blank-filters.js +0 -156
  90. package/build/test/test-ci-catalog.js +0 -177
  91. package/build/test/test-ci-lint.js +0 -191
  92. package/build/test/test-ci-variables.js +0 -309
  93. package/build/test/test-create-repository.js +0 -120
  94. package/build/test/test-dependency-proxy.js +0 -195
  95. package/build/test/test-deployment-tools.js +0 -463
  96. package/build/test/test-download-attachment.js +0 -190
  97. package/build/test/test-dynamic-project-scope.js +0 -524
  98. package/build/test/test-empty-toolsets.js +0 -535
  99. package/build/test/test-get-file-blame.js +0 -145
  100. package/build/test/test-get-file-contents.js +0 -128
  101. package/build/test/test-geteffectiveprojectid.js +0 -812
  102. package/build/test/test-health-check.js +0 -115
  103. package/build/test/test-issue-description-patch.js +0 -256
  104. package/build/test/test-job-artifacts.js +0 -275
  105. package/build/test/test-json-schema.js +0 -166
  106. package/build/test/test-list-group-members.js +0 -150
  107. package/build/test/test-list-group-merge-requests.js +0 -165
  108. package/build/test/test-list-issues.js +0 -170
  109. package/build/test/test-list-merge-requests.js +0 -201
  110. package/build/test/test-list-milestones.js +0 -145
  111. package/build/test/test-list-project-members.js +0 -132
  112. package/build/test/test-merge-request-approval-state-tools.js +0 -213
  113. package/build/test/test-merge-request-approvals.js +0 -187
  114. package/build/test/test-merge-request-pipelines.js +0 -106
  115. package/build/test/test-mr-diffs-filter.js +0 -132
  116. package/build/test/test-mr-file-diffs.js +0 -251
  117. package/build/test/test-oauth-proxy-rate-limit.js +0 -133
  118. package/build/test/test-orbit.js +0 -155
  119. package/build/test/test-permission-mode.js +0 -424
  120. package/build/test/test-pipeline-schedules.js +0 -482
  121. package/build/test/test-protected-branches.js +0 -174
  122. package/build/test/test-remote-downloads.js +0 -510
  123. package/build/test/test-search-code.js +0 -275
  124. package/build/test/test-snippets.js +0 -735
  125. package/build/test/test-tags.js +0 -220
  126. package/build/test/test-todos.js +0 -195
  127. package/build/test/test-token-optimizations.js +0 -691
  128. package/build/test/test-toolset-filtering.js +0 -499
  129. package/build/test/test-update-issue-slim.js +0 -141
  130. package/build/test/test-update-project.js +0 -112
  131. package/build/test/test-upload-markdown.js +0 -206
  132. package/build/test/test-vulnerabilities.js +0 -399
  133. package/build/test/test-webhooks.js +0 -218
  134. package/build/test/tool-description-quality.test.js +0 -50
  135. package/build/test/utils/bulk-publish-options.test.js +0 -32
  136. package/build/test/utils/download-token.test.js +0 -35
  137. package/build/test/utils/forwarded-public-base-url.test.js +0 -46
  138. package/build/test/utils/gitlab-instance-version.test.js +0 -32
  139. package/build/test/utils/gitlab-version-gate.test.js +0 -74
  140. package/build/test/utils/graphql-query.test.js +0 -168
  141. package/build/test/utils/helpers-path.test.js +0 -104
  142. package/build/test/utils/jmespath-tool-result.test.js +0 -91
  143. package/build/test/utils/masking.test.js +0 -384
  144. package/build/test/utils/merge-request-position.test.js +0 -57
  145. package/build/test/utils/mock-gitlab-server.js +0 -614
  146. package/build/test/utils/proxy-client-ip.test.js +0 -28
  147. package/build/test/utils/redact-sensitive.test.js +0 -32
  148. package/build/test/utils/safe-redirect-fetch.test.js +0 -463
  149. package/build/test/utils/server-launcher.js +0 -231
  150. package/build/test/utils/tool-args.test.js +0 -210
  151. package/build/test/utils/url.test.js +0 -128
  152. package/build/test/utils/version-check.test.js +0 -239
  153. package/build/test/utils/wiki-title.test.js +0 -21
  154. package/build/test-note.js +0 -54
  155. package/build/test-resolve-issue-note.js +0 -127
@@ -1,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
- });