@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,484 +0,0 @@
1
- import { describe, test, before, after } from "node:test";
2
- import assert from "node:assert";
3
- import { spawn } from "child_process";
4
- import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js";
5
- const MOCK_TOKEN = "glpat-mock-token-pipeline-schedules";
6
- const TEST_PROJECT_ID = "123";
7
- const TEST_SCHEDULE_ID = "13";
8
- const MISSING_SCHEDULE_ID = "999";
9
- const TEST_VAR_KEY = "NIGHTLY_TARGET";
10
- const MOCK_OWNER = {
11
- name: "Administrator",
12
- username: "root",
13
- id: 1,
14
- state: "active",
15
- avatar_url: "https://gitlab.example.com/uploads/-/system/user/avatar/1/avatar.png",
16
- web_url: "https://gitlab.example.com/root",
17
- };
18
- const MOCK_SCHEDULE = {
19
- id: Number(TEST_SCHEDULE_ID),
20
- description: "Nightly build",
21
- ref: "refs/heads/main",
22
- cron: "0 1 * * *",
23
- cron_timezone: "UTC",
24
- next_run_at: "2026-09-01T01:00:00.000Z",
25
- active: true,
26
- created_at: "2026-08-01T13:31:08.849Z",
27
- updated_at: "2026-08-01T13:40:17.727Z",
28
- owner: MOCK_OWNER,
29
- };
30
- const MOCK_INACTIVE_SCHEDULE = {
31
- ...MOCK_SCHEDULE,
32
- id: 14,
33
- description: "Paused weekly deploy",
34
- cron: "0 3 * * 5",
35
- active: false,
36
- };
37
- const MOCK_SCHEDULE_DETAIL = {
38
- ...MOCK_SCHEDULE,
39
- last_pipeline: {
40
- id: 332,
41
- sha: "0e788619d0b5ec17388dffb973ecd505946156db",
42
- ref: "refs/heads/main",
43
- status: "success",
44
- },
45
- variables: [{ key: TEST_VAR_KEY, variable_type: "env_var", value: "staging", raw: false }],
46
- inputs: [{ name: "deploy_strategy", value: "blue-green" }],
47
- };
48
- const MOCK_SCHEDULE_PIPELINE = {
49
- id: 47,
50
- iid: 12,
51
- project_id: Number(TEST_PROJECT_ID),
52
- status: "success",
53
- source: "schedule",
54
- ref: "refs/heads/main",
55
- sha: "a91957a858320c0e17f3a0eca7cfacbff50ea29a",
56
- web_url: "https://gitlab.example.com/foo/bar/-/pipelines/47",
57
- created_at: "2026-08-20T11:28:34.085Z",
58
- updated_at: "2026-08-20T11:32:35.169Z",
59
- };
60
- const MOCK_VARIABLE = {
61
- key: TEST_VAR_KEY,
62
- variable_type: "env_var",
63
- value: "staging",
64
- };
65
- async function callTool(toolName, args, env) {
66
- return new Promise((resolve, reject) => {
67
- const proc = spawn("node", ["build/index.js"], {
68
- stdio: ["pipe", "pipe", "pipe"],
69
- env: { ...process.env, ...env },
70
- });
71
- let output = "";
72
- let errorOutput = "";
73
- proc.stdout?.on("data", (d) => (output += d));
74
- proc.stderr?.on("data", (d) => (errorOutput += d));
75
- proc.on("close", code => {
76
- if (code !== 0) {
77
- return reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
78
- }
79
- const line = output.split("\n").find(l => l.startsWith("{"));
80
- if (!line)
81
- return reject(new Error("No JSON output found"));
82
- try {
83
- const response = JSON.parse(line);
84
- if (response.error) {
85
- reject(new Error(response.error?.message ?? String(response.error)));
86
- }
87
- else {
88
- resolve(response.result);
89
- }
90
- }
91
- catch (e) {
92
- reject(e);
93
- }
94
- });
95
- proc.stdin?.end(JSON.stringify({
96
- jsonrpc: "2.0",
97
- id: 1,
98
- method: "tools/call",
99
- params: { name: toolName, arguments: args },
100
- }) + "\n");
101
- });
102
- }
103
- /** Tools that return JSON return it as the first content block's text. */
104
- async function callToolJson(toolName, args, env) {
105
- const result = await callTool(toolName, args, env);
106
- const content = result?.content?.[0]?.text;
107
- return content ? JSON.parse(content) : result;
108
- }
109
- /** Tools that return a human summary return it as the first content block's text. */
110
- async function callToolText(toolName, args, env) {
111
- const result = await callTool(toolName, args, env);
112
- return result?.content?.[0]?.text ?? "";
113
- }
114
- function listTools(env) {
115
- return new Promise((resolve, reject) => {
116
- const proc = spawn("node", ["build/index.js"], {
117
- stdio: ["pipe", "pipe", "pipe"],
118
- env: { ...process.env, ...env },
119
- });
120
- let output = "";
121
- proc.stdout?.on("data", (d) => (output += d));
122
- proc.on("close", () => {
123
- try {
124
- const line = output.split("\n").find(l => l.startsWith("{"));
125
- if (!line)
126
- return reject(new Error("No JSON output found"));
127
- const response = JSON.parse(line);
128
- resolve((response.result?.tools ?? []).map((t) => t.name));
129
- }
130
- catch (e) {
131
- reject(e);
132
- }
133
- });
134
- proc.stdin?.end(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }) + "\n");
135
- });
136
- }
137
- describe("Pipeline schedule tools", () => {
138
- let mockServer;
139
- let mockApiUrl;
140
- let baseEnv;
141
- let lastReceivedScope;
142
- let lastReceivedBody;
143
- let lastReceivedQuery;
144
- // Read through a function so control-flow narrowing from the `= undefined`
145
- // reset before each call does not collapse the type to `never`.
146
- const capturedBody = () => lastReceivedBody;
147
- const capturedQuery = () => lastReceivedQuery;
148
- const schedulesPath = `/projects/${TEST_PROJECT_ID}/pipeline_schedules`;
149
- const schedulePath = `${schedulesPath}/${TEST_SCHEDULE_ID}`;
150
- const variablesPath = `${schedulePath}/variables`;
151
- const variablePath = `${variablesPath}/${TEST_VAR_KEY}`;
152
- before(async () => {
153
- mockServer = new MockGitLabServer({
154
- port: await findMockServerPort(),
155
- validTokens: [MOCK_TOKEN],
156
- });
157
- // --- Schedule endpoints ---
158
- mockServer.addMockHandler("get", schedulesPath, (req, res) => {
159
- const scope = req.query.scope;
160
- lastReceivedScope = scope;
161
- if (scope === "inactive") {
162
- res.json([MOCK_INACTIVE_SCHEDULE]);
163
- }
164
- else if (scope === "active") {
165
- res.json([MOCK_SCHEDULE]);
166
- }
167
- else {
168
- res.json([MOCK_SCHEDULE, MOCK_INACTIVE_SCHEDULE]);
169
- }
170
- });
171
- mockServer.addMockHandler("get", schedulePath, (_req, res) => {
172
- res.json(MOCK_SCHEDULE_DETAIL);
173
- });
174
- mockServer.addMockHandler("get", `${schedulesPath}/${MISSING_SCHEDULE_ID}`, (_req, res) => {
175
- res.status(404).json({ message: "404 Not found" });
176
- });
177
- mockServer.addMockHandler("get", `${schedulePath}/pipelines`, (req, res) => {
178
- lastReceivedQuery = req.query;
179
- res.json([MOCK_SCHEDULE_PIPELINE]);
180
- });
181
- mockServer.addMockHandler("post", schedulesPath, (req, res) => {
182
- lastReceivedBody = req.body;
183
- res.status(201).json({ ...MOCK_SCHEDULE, ...req.body, id: 14 });
184
- });
185
- mockServer.addMockHandler("put", schedulePath, (req, res) => {
186
- lastReceivedBody = req.body;
187
- res.json({ ...MOCK_SCHEDULE, ...req.body });
188
- });
189
- mockServer.addMockHandler("delete", schedulePath, (_req, res) => {
190
- res.json(MOCK_SCHEDULE);
191
- });
192
- mockServer.addMockHandler("post", `${schedulePath}/play`, (_req, res) => {
193
- res.status(201).json({ message: "201 Created" });
194
- });
195
- mockServer.addMockHandler("post", `${schedulePath}/take_ownership`, (_req, res) => {
196
- res.json({
197
- ...MOCK_SCHEDULE,
198
- owner: { ...MOCK_OWNER, id: 50, name: "Ada", username: "ada" },
199
- });
200
- });
201
- // --- Schedule variable endpoints ---
202
- mockServer.addMockHandler("get", variablePath, (_req, res) => {
203
- res.json(MOCK_VARIABLE);
204
- });
205
- mockServer.addMockHandler("post", variablesPath, (req, res) => {
206
- lastReceivedBody = req.body;
207
- res.status(201).json({ ...MOCK_VARIABLE, ...req.body });
208
- });
209
- mockServer.addMockHandler("put", variablePath, (req, res) => {
210
- lastReceivedBody = req.body;
211
- res.json({ ...MOCK_VARIABLE, ...req.body });
212
- });
213
- // GitLab documents a 200 with the deleted variable, but self-managed
214
- // instances have been seen answering 204; neither body is parsed.
215
- mockServer.addMockHandler("delete", variablePath, (_req, res) => {
216
- res.status(204).send();
217
- });
218
- await mockServer.start();
219
- mockApiUrl = `${mockServer.getUrl()}/api/v4`;
220
- baseEnv = {
221
- GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
222
- GITLAB_API_URL: mockApiUrl,
223
- GITLAB_TOOLSETS: "pipelines",
224
- };
225
- });
226
- after(async () => {
227
- await mockServer.stop();
228
- });
229
- // --- Reads ---
230
- test("list_pipeline_schedules returns every schedule when no scope is given", async () => {
231
- lastReceivedScope = undefined;
232
- const result = await callToolJson("list_pipeline_schedules", { project_id: TEST_PROJECT_ID }, baseEnv);
233
- assert.ok(Array.isArray(result));
234
- assert.strictEqual(result.length, 2);
235
- assert.strictEqual(result[0].description, "Nightly build");
236
- assert.strictEqual(result[0].cron, "0 1 * * *");
237
- assert.strictEqual(result[0].active, true);
238
- assert.strictEqual(lastReceivedScope, undefined);
239
- });
240
- test("list_pipeline_schedules forwards scope to GitLab", async () => {
241
- lastReceivedScope = undefined;
242
- const result = await callToolJson("list_pipeline_schedules", { project_id: TEST_PROJECT_ID, scope: "inactive" }, baseEnv);
243
- assert.strictEqual(lastReceivedScope, "inactive");
244
- assert.strictEqual(result.length, 1);
245
- assert.strictEqual(result[0].active, false);
246
- assert.strictEqual(result[0].description, "Paused weekly deploy");
247
- });
248
- test("get_pipeline_schedule returns variables, inputs and last pipeline", async () => {
249
- const result = await callToolJson("get_pipeline_schedule", { project_id: TEST_PROJECT_ID, pipeline_schedule_id: TEST_SCHEDULE_ID }, baseEnv);
250
- assert.strictEqual(result.id, TEST_SCHEDULE_ID);
251
- assert.strictEqual(result.description, "Nightly build");
252
- assert.strictEqual(result.owner.username, "root");
253
- assert.strictEqual(result.last_pipeline.status, "success");
254
- assert.strictEqual(result.variables[0].key, TEST_VAR_KEY);
255
- assert.strictEqual(result.inputs[0].name, "deploy_strategy");
256
- });
257
- test("get_pipeline_schedule surfaces a 404 as an error", async () => {
258
- const result = await callTool("get_pipeline_schedule", { project_id: TEST_PROJECT_ID, pipeline_schedule_id: MISSING_SCHEDULE_ID }, baseEnv);
259
- assert.strictEqual(result.isError, true);
260
- assert.match(result.content?.[0]?.text ?? "", /404/);
261
- });
262
- test("list_pipeline_schedule_pipelines returns the triggered pipelines", async () => {
263
- const result = await callToolJson("list_pipeline_schedule_pipelines", { project_id: TEST_PROJECT_ID, pipeline_schedule_id: TEST_SCHEDULE_ID }, baseEnv);
264
- assert.ok(Array.isArray(result));
265
- assert.strictEqual(result.length, 1);
266
- assert.strictEqual(result[0].id, "47");
267
- assert.strictEqual(result[0].status, "success");
268
- assert.strictEqual(result[0].source, "schedule");
269
- });
270
- test("list_pipeline_schedule_pipelines forwards the endpoint's filters", async () => {
271
- lastReceivedQuery = undefined;
272
- await callToolJson("list_pipeline_schedule_pipelines", {
273
- project_id: TEST_PROJECT_ID,
274
- pipeline_schedule_id: TEST_SCHEDULE_ID,
275
- scope: "finished",
276
- status: "success",
277
- sort: "desc",
278
- updated_after: "2026-08-01T00:00:00Z",
279
- created_before: "2026-08-31T00:00:00Z",
280
- }, baseEnv);
281
- // Express exposes req.query as a null-prototype object; spread to compare.
282
- assert.deepStrictEqual({ ...capturedQuery() }, {
283
- scope: "finished",
284
- status: "success",
285
- sort: "desc",
286
- updated_after: "2026-08-01T00:00:00Z",
287
- created_before: "2026-08-31T00:00:00Z",
288
- });
289
- });
290
- // --- Mutations ---
291
- test("create_pipeline_schedule posts cron, description and ref", async () => {
292
- lastReceivedBody = undefined;
293
- const text = await callToolText("create_pipeline_schedule", {
294
- project_id: TEST_PROJECT_ID,
295
- description: "Nightly build",
296
- ref: "refs/heads/main",
297
- cron: "0 1 * * *",
298
- cron_timezone: "Asia/Tokyo",
299
- }, baseEnv);
300
- assert.strictEqual(capturedBody()?.cron, "0 1 * * *");
301
- assert.strictEqual(capturedBody()?.description, "Nightly build");
302
- assert.strictEqual(capturedBody()?.ref, "refs/heads/main");
303
- assert.strictEqual(capturedBody()?.cron_timezone, "Asia/Tokyo");
304
- assert.match(text, /Created pipeline schedule #14/);
305
- });
306
- test("create_pipeline_schedule sends active=false rather than coercing it to true", async () => {
307
- lastReceivedBody = undefined;
308
- await callToolText("create_pipeline_schedule", {
309
- project_id: TEST_PROJECT_ID,
310
- description: "Paused weekly deploy",
311
- ref: "main",
312
- cron: "0 3 * * 5",
313
- active: "false",
314
- }, baseEnv);
315
- assert.strictEqual(capturedBody()?.active, false);
316
- });
317
- test("create_pipeline_schedule accepts non-string values inside an input array", async () => {
318
- lastReceivedBody = undefined;
319
- await callToolText("create_pipeline_schedule", {
320
- project_id: TEST_PROJECT_ID,
321
- description: "Nightly build",
322
- ref: "main",
323
- cron: "0 1 * * *",
324
- inputs: [
325
- { name: "retries", value: 3 },
326
- { name: "verbose", value: true },
327
- { name: "targets", value: ["staging", 7, { region: "eu" }] },
328
- ],
329
- }, baseEnv);
330
- assert.deepStrictEqual(capturedBody()?.inputs, [
331
- { name: "retries", value: 3 },
332
- { name: "verbose", value: true },
333
- { name: "targets", value: ["staging", 7, { region: "eu" }] },
334
- ]);
335
- });
336
- test("create_pipeline_schedule rejects a map as a top-level input value", async () => {
337
- await assert.rejects(() => callTool("create_pipeline_schedule", {
338
- project_id: TEST_PROJECT_ID,
339
- description: "Nightly build",
340
- ref: "main",
341
- cron: "0 1 * * *",
342
- inputs: [{ name: "matrix", value: { os: "linux" } }],
343
- }, baseEnv), /invalid|expected|value/i);
344
- });
345
- test("create_pipeline_schedule_variable rejects an invalid key", async () => {
346
- await assert.rejects(() => callTool("create_pipeline_schedule_variable", {
347
- project_id: TEST_PROJECT_ID,
348
- pipeline_schedule_id: TEST_SCHEDULE_ID,
349
- key: "not-a-valid-key",
350
- value: "x",
351
- }, baseEnv), /invalid|regex|pattern|key/i);
352
- });
353
- test("update_pipeline_schedule sends only the supplied fields", async () => {
354
- lastReceivedBody = undefined;
355
- const text = await callToolText("update_pipeline_schedule", {
356
- project_id: TEST_PROJECT_ID,
357
- pipeline_schedule_id: TEST_SCHEDULE_ID,
358
- cron: "0 2 * * *",
359
- }, baseEnv);
360
- assert.deepStrictEqual(capturedBody(), { cron: "0 2 * * *" });
361
- assert.match(text, /Updated pipeline schedule #13/);
362
- assert.match(text, /0 2 \* \* \*/);
363
- });
364
- test("delete_pipeline_schedule reports the removed schedule id", async () => {
365
- const text = await callToolText("delete_pipeline_schedule", { project_id: TEST_PROJECT_ID, pipeline_schedule_id: TEST_SCHEDULE_ID }, baseEnv);
366
- assert.match(text, /Deleted pipeline schedule #13/);
367
- });
368
- test("play_pipeline_schedule reports GitLab's acknowledgement", async () => {
369
- const text = await callToolText("play_pipeline_schedule", { project_id: TEST_PROJECT_ID, pipeline_schedule_id: TEST_SCHEDULE_ID }, baseEnv);
370
- assert.match(text, /Ran pipeline schedule #13/);
371
- assert.match(text, /201 Created/);
372
- assert.match(text, /next scheduled run is unchanged/);
373
- });
374
- test("take_ownership_pipeline_schedule reports the new owner", async () => {
375
- const text = await callToolText("take_ownership_pipeline_schedule", { project_id: TEST_PROJECT_ID, pipeline_schedule_id: TEST_SCHEDULE_ID }, baseEnv);
376
- assert.match(text, /Took ownership of pipeline schedule #13/);
377
- assert.match(text, /ada/);
378
- });
379
- // --- Schedule variables ---
380
- test("get_pipeline_schedule_variable returns the variable", async () => {
381
- const result = await callToolJson("get_pipeline_schedule_variable", {
382
- project_id: TEST_PROJECT_ID,
383
- pipeline_schedule_id: TEST_SCHEDULE_ID,
384
- key: TEST_VAR_KEY,
385
- }, baseEnv);
386
- assert.strictEqual(result.key, TEST_VAR_KEY);
387
- assert.strictEqual(result.value, "staging");
388
- assert.strictEqual(result.variable_type, "env_var");
389
- });
390
- test("create_pipeline_schedule_variable posts key, value and type", async () => {
391
- lastReceivedBody = undefined;
392
- const text = await callToolText("create_pipeline_schedule_variable", {
393
- project_id: TEST_PROJECT_ID,
394
- pipeline_schedule_id: TEST_SCHEDULE_ID,
395
- key: TEST_VAR_KEY,
396
- value: "production",
397
- variable_type: "env_var",
398
- }, baseEnv);
399
- assert.deepStrictEqual(capturedBody(), {
400
- key: TEST_VAR_KEY,
401
- value: "production",
402
- variable_type: "env_var",
403
- });
404
- assert.match(text, new RegExp(`Created variable '${TEST_VAR_KEY}'`));
405
- });
406
- test("update_pipeline_schedule_variable puts the new value", async () => {
407
- lastReceivedBody = undefined;
408
- const text = await callToolText("update_pipeline_schedule_variable", {
409
- project_id: TEST_PROJECT_ID,
410
- pipeline_schedule_id: TEST_SCHEDULE_ID,
411
- key: TEST_VAR_KEY,
412
- value: "production",
413
- }, baseEnv);
414
- assert.deepStrictEqual(capturedBody(), { value: "production" });
415
- assert.match(text, new RegExp(`Updated variable '${TEST_VAR_KEY}'`));
416
- });
417
- test("delete_pipeline_schedule_variable reports the removed key", async () => {
418
- const text = await callToolText("delete_pipeline_schedule_variable", {
419
- project_id: TEST_PROJECT_ID,
420
- pipeline_schedule_id: TEST_SCHEDULE_ID,
421
- key: TEST_VAR_KEY,
422
- }, baseEnv);
423
- assert.match(text, new RegExp(`Deleted variable '${TEST_VAR_KEY}'`));
424
- assert.match(text, /pipeline schedule #13/);
425
- });
426
- // --- Toolset and permission behaviour ---
427
- test("pipeline schedule tools are absent when the pipelines toolset is not activated", async () => {
428
- const names = await listTools({
429
- GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
430
- GITLAB_API_URL: mockApiUrl,
431
- // No GITLAB_TOOLSETS — pipelines is not a default toolset
432
- });
433
- assert.ok(!names.includes("list_pipeline_schedules"), "list should be opt-in");
434
- assert.ok(!names.includes("create_pipeline_schedule"), "create should be opt-in");
435
- assert.ok(!names.includes("delete_pipeline_schedule"), "delete should be opt-in");
436
- });
437
- test("USE_PIPELINE=true exposes the pipeline schedule tools", async () => {
438
- const names = await listTools({
439
- GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
440
- GITLAB_API_URL: mockApiUrl,
441
- USE_PIPELINE: "true",
442
- });
443
- assert.ok(names.includes("list_pipeline_schedules"), "list should be exposed");
444
- assert.ok(names.includes("play_pipeline_schedule"), "play should be exposed");
445
- assert.ok(names.includes("delete_pipeline_schedule_variable"), "variable delete should be exposed");
446
- });
447
- test("write tools are absent from tools/list in read-only mode", async () => {
448
- const names = await listTools({ ...baseEnv, GITLAB_READ_ONLY_MODE: "true" });
449
- for (const name of [
450
- "create_pipeline_schedule",
451
- "update_pipeline_schedule",
452
- "delete_pipeline_schedule",
453
- "play_pipeline_schedule",
454
- "take_ownership_pipeline_schedule",
455
- "create_pipeline_schedule_variable",
456
- "update_pipeline_schedule_variable",
457
- "delete_pipeline_schedule_variable",
458
- ]) {
459
- assert.ok(!names.includes(name), `${name} should be absent in read-only mode`);
460
- }
461
- for (const name of [
462
- "list_pipeline_schedules",
463
- "get_pipeline_schedule",
464
- "list_pipeline_schedule_pipelines",
465
- "get_pipeline_schedule_variable",
466
- ]) {
467
- assert.ok(names.includes(name), `${name} should be present in read-only mode`);
468
- }
469
- });
470
- test("read-only mode rejects a create_pipeline_schedule call", async () => {
471
- await assert.rejects(() => callTool("create_pipeline_schedule", {
472
- project_id: TEST_PROJECT_ID,
473
- description: "Nightly build",
474
- ref: "main",
475
- cron: "0 1 * * *",
476
- }, { ...baseEnv, GITLAB_READ_ONLY_MODE: "true" }), /read-only|not allowed|Unknown tool/i);
477
- });
478
- test("permission mode 'modify' blocks the delete tools", async () => {
479
- const names = await listTools({ ...baseEnv, GITLAB_PERMISSION_MODE: "modify" });
480
- assert.ok(!names.includes("delete_pipeline_schedule"), "delete_pipeline_schedule should be blocked in modify mode");
481
- assert.ok(!names.includes("delete_pipeline_schedule_variable"), "delete_pipeline_schedule_variable should be blocked in modify mode");
482
- assert.ok(names.includes("update_pipeline_schedule"), "update_pipeline_schedule should remain available in modify mode");
483
- });
484
- });
@@ -1,174 +0,0 @@
1
- import { after, before, describe, test } from "node:test";
2
- import assert from "node:assert";
3
- import { spawn } from "child_process";
4
- import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js";
5
- const MOCK_TOKEN = "glpat-mock-token-protected-branches";
6
- const TEST_PROJECT_ID = "123";
7
- const TEST_BRANCH = "main";
8
- function buildProtectedBranch(overrides = {}) {
9
- return {
10
- id: 1,
11
- name: TEST_BRANCH,
12
- push_access_levels: [
13
- {
14
- access_level: 30,
15
- access_level_description: "Developers + Maintainers",
16
- user_id: null,
17
- group_id: null,
18
- },
19
- ],
20
- merge_access_levels: [
21
- {
22
- access_level: 40,
23
- access_level_description: "Maintainers",
24
- user_id: null,
25
- group_id: null,
26
- },
27
- ],
28
- unprotect_access_levels: [
29
- {
30
- access_level: 60,
31
- access_level_description: "Administrators",
32
- user_id: null,
33
- group_id: null,
34
- },
35
- ],
36
- allow_force_push: false,
37
- code_owner_approval_required: false,
38
- ...overrides,
39
- };
40
- }
41
- async function callTool(toolName, args, env) {
42
- return new Promise((resolve, reject) => {
43
- const proc = spawn("node", ["build/index.js"], {
44
- stdio: ["pipe", "pipe", "pipe"],
45
- env: {
46
- ...process.env,
47
- ...env,
48
- },
49
- });
50
- let output = "";
51
- let errorOutput = "";
52
- proc.stdout?.on("data", (d) => (output += d));
53
- proc.stderr?.on("data", (d) => (errorOutput += d));
54
- proc.on("close", code => {
55
- if (code !== 0) {
56
- reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
57
- return;
58
- }
59
- const line = output.split("\n").find(l => l.startsWith("{"));
60
- if (!line) {
61
- reject(new Error("No JSON output found"));
62
- return;
63
- }
64
- const response = JSON.parse(line);
65
- if (response.error) {
66
- reject(new Error(response.error.message ?? JSON.stringify(response.error)));
67
- return;
68
- }
69
- const content = response.result?.content?.[0]?.text;
70
- if (!content) {
71
- resolve(response.result);
72
- return;
73
- }
74
- try {
75
- resolve(JSON.parse(content));
76
- }
77
- catch {
78
- resolve(content);
79
- }
80
- });
81
- proc.stdin?.end(JSON.stringify({
82
- jsonrpc: "2.0",
83
- id: 1,
84
- method: "tools/call",
85
- params: { name: toolName, arguments: args },
86
- }) + "\n");
87
- });
88
- }
89
- describe("protected branch tools", () => {
90
- let mockGitLab;
91
- let mockGitLabUrl;
92
- before(async () => {
93
- const mockPort = await findMockServerPort();
94
- mockGitLab = new MockGitLabServer({
95
- port: mockPort,
96
- validTokens: [MOCK_TOKEN],
97
- });
98
- mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/protected_branches`, (req, res) => {
99
- assert.strictEqual(req.query.search, "main");
100
- assert.strictEqual(req.query.page, "2");
101
- assert.strictEqual(req.query.per_page, "10");
102
- res.json([buildProtectedBranch()]);
103
- });
104
- mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/protected_branches/${TEST_BRANCH}`, (_req, res) => {
105
- res.json(buildProtectedBranch());
106
- });
107
- mockGitLab.addMockHandler("post", `/projects/${TEST_PROJECT_ID}/protected_branches`, (req, res) => {
108
- assert.deepStrictEqual(req.body, {
109
- name: TEST_BRANCH,
110
- push_access_level: 30,
111
- merge_access_level: 40,
112
- unprotect_access_level: 60,
113
- allow_force_push: false,
114
- code_owner_approval_required: false,
115
- });
116
- res.status(201).json(buildProtectedBranch());
117
- });
118
- mockGitLab.addMockHandler("delete", `/projects/${TEST_PROJECT_ID}/protected_branches/${TEST_BRANCH}`, (_req, res) => {
119
- res.status(204).send();
120
- });
121
- mockGitLab.addMockHandler("put", `/projects/${TEST_PROJECT_ID}`, (req, res) => {
122
- assert.deepStrictEqual(req.body, { default_branch: TEST_BRANCH });
123
- res.json({ id: Number(TEST_PROJECT_ID), default_branch: TEST_BRANCH });
124
- });
125
- await mockGitLab.start();
126
- mockGitLabUrl = mockGitLab.getUrl();
127
- });
128
- after(async () => {
129
- await mockGitLab.stop();
130
- });
131
- const env = () => ({
132
- GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
133
- GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
134
- GITLAB_TOOLSETS: "branches",
135
- });
136
- test("list_protected_branches forwards search and pagination", async () => {
137
- const result = await callTool("list_protected_branches", { project_id: TEST_PROJECT_ID, search: "main", page: 2, per_page: 10 }, env());
138
- assert.ok(Array.isArray(result));
139
- assert.strictEqual(result[0].name, TEST_BRANCH);
140
- });
141
- test("get_protected_branch parses a single protected branch", async () => {
142
- const result = await callTool("get_protected_branch", { project_id: TEST_PROJECT_ID, branch_name: TEST_BRANCH }, env());
143
- assert.strictEqual(result.name, TEST_BRANCH);
144
- assert.strictEqual(result.allow_force_push, false);
145
- });
146
- test("protect_branch posts normalized branch name, access levels, and false booleans", async () => {
147
- const result = await callTool("protect_branch", {
148
- project_id: TEST_PROJECT_ID,
149
- branch_name: TEST_BRANCH,
150
- push_access_level: "30",
151
- merge_access_level: 40,
152
- unprotect_access_level: 60,
153
- allow_force_push: "false",
154
- code_owner_approval_required: "false",
155
- }, env());
156
- assert.strictEqual(result.name, TEST_BRANCH);
157
- });
158
- test("protect_branch rejects invalid access levels before calling GitLab", async () => {
159
- await assert.rejects(() => callTool("protect_branch", {
160
- project_id: TEST_PROJECT_ID,
161
- branch_name: TEST_BRANCH,
162
- push_access_level: 99,
163
- }, env()), /Access level must be one of/);
164
- });
165
- test("unprotect_branch sends DELETE and returns status", async () => {
166
- const result = await callTool("unprotect_branch", { project_id: TEST_PROJECT_ID, branch_name: TEST_BRANCH }, env());
167
- assert.deepStrictEqual(result, { status: "unprotected", branch: TEST_BRANCH });
168
- });
169
- test("update_default_branch sends the expected PUT body", async () => {
170
- const result = await callTool("update_default_branch", { project_id: TEST_PROJECT_ID, default_branch: TEST_BRANCH }, env());
171
- assert.strictEqual(result.status, "updated");
172
- assert.strictEqual(result.default_branch, TEST_BRANCH);
173
- });
174
- });